From 1149bf9c55284dda32e387568f131a17d714cdd2 Mon Sep 17 00:00:00 2001 From: Banks T Date: Sun, 12 Apr 2020 18:37:14 -0400 Subject: [PATCH] Pipeline Bringup (#94) * Refactor package structure, various cleanups * Add pipeline classes, settings, separate enums * updated Largest ContourSortMode and added centermost * Add DriverPipeline classes, apply spotless * Add crosshair to DriverMode, cleanups * Add FrameStaticProperties as member in Frame Add FrameStaticProperties as member in Frame * Finish ReflectivePipeline, various tweaks * Apply Spotless * Move test images * add Releasable interface, implement in classes * add TestUtils class, move testimages * Refactor CVPipeline, add ReflectivePipelineTest * Fix ConcurrentModificationException bug in group contours pipe with potential targets * Resolve memory leaks due to unnecessary instantiation of Points * Apply spotless * Add CVMat, ReflectionUtils to help track rogue Mats * various cleanups, add DummyFrameConsumer * Add logback * Add slv4j logger to replace the current debugLogger I'm waiting on stuff to be less skeletoned to add more * Add perimeter, MatOfPoint2f getters to Contour * Create CornerDetectionPipe based on old solvePNPPipe * Add ContourShape class for approxPolyDp Start on ColoredShape tracking * Add point detection, fix convex hull calculation in Contour * Make Draw2dContours pipe respect showMultiple * Update Contour.java * Clean up draw 3d, fix convex hull bug in corner detection * Update geometry classes * Add lifecam calibration data * Implement solvePNP, bounding box top and bottom * Fix JSON mat bug and lifecam default calibration for tests, fix 3d drawing * run spotless * Refactor calibration into `common.calibration` * Update .gitignore * Add offset method to get2020Target * Various cleanups, add PipelineType enum * Apply spotless Co-authored-by: ori agranat Co-authored-by: Matt --- .gitignore | 1 + chameleon-server/build.gradle | 7 +- .../java/com/chameleonvision/_2/Main.java | 32 +- .../_2/config/CameraCalibrationConfig.java | 27 +- .../_2/config/CameraConfig.java | 24 +- .../_2/config/CameraJsonConfig.java | 9 +- .../_2/config/ConfigManager.java | 16 +- .../_2/config/FullCameraConfiguration.java | 8 +- .../_2/config/GeneralSettings.java | 16 +- .../chameleonvision/_2/config/JsonMat.java | 46 +- .../_2/config/PipelineConfig.java | 22 +- .../config/serializers/BaseDeserializer.java | 34 +- .../_2/config/serializers/BaseSerializer.java | 8 +- ...tandardCVPipelineSettingsDeserializer.java | 26 +- .../StandardCVPipelineSettingsSerializer.java | 8 +- .../_2/network/LinuxNetworking.java | 8 +- .../_2/network/NetmaskToCIDR.java | 3 +- .../_2/network/NetworkInterface.java | 43 +- .../_2/network/NetworkManager.java | 9 +- .../_2/network/SysNetworking.java | 48 +- .../_2/network/WindowsNetworking.java | 75 +- .../com/chameleonvision/_2/util/Helpers.java | 44 +- .../_2/util/ProgramDirectoryUtilities.java | 21 +- .../_2/vision/VisionManager.java | 95 +- .../_2/vision/VisionProcess.java | 113 ++- .../_2/vision/camera/CameraCapture.java | 38 +- .../_2/vision/camera/CameraStreamer.java | 51 +- .../camera/CaptureStaticProperties.java | 10 +- .../_2/vision/camera/USBCameraCapture.java | 39 +- .../vision/camera/USBCaptureProperties.java | 30 +- .../_2/vision/enums/CalibrationMode.java | 4 +- .../_2/vision/enums/ImageRotationMode.java | 4 +- .../_2/vision/enums/SortMode.java | 8 +- .../_2/vision/enums/TargetIntersection.java | 6 +- .../_2/vision/enums/TargetOrientation.java | 3 +- .../_2/vision/enums/TargetRegion.java | 6 +- .../_2/vision/image/CaptureProperties.java | 9 +- .../_2/vision/image/ImageCapture.java | 7 +- .../_2/vision/image/StaticImageCapture.java | 7 +- .../_2/vision/pipeline/CVPipeline.java | 8 +- .../_2/vision/pipeline/CVPipelineResult.java | 5 +- .../_2/vision/pipeline/Pipe.java | 8 +- .../_2/vision/pipeline/PipelineManager.java | 50 +- .../pipeline/impl/Calibrate3dPipeline.java | 54 +- .../pipeline/impl/DriverVisionPipeline.java | 18 +- .../impl/StandardCVPipelineSettings.java | 13 +- .../_2/vision/pipeline/pipes/BlurPipe.java | 25 +- .../pipeline/pipes/Collect2dTargetsPipe.java | 80 +- .../pipeline/pipes/Draw2dContoursPipe.java | 54 +- .../pipeline/pipes/Draw2dCrosshairPipe.java | 65 +- .../pipeline/pipes/DrawSolvePNPPipe.java | 30 +- .../pipeline/pipes/ErodeDilatePipe.java | 8 +- .../pipeline/pipes/FilterContoursPipe.java | 25 +- .../pipeline/pipes/FindContoursPipe.java | 13 +- .../pipeline/pipes/GroupContoursPipe.java | 196 ++-- .../_2/vision/pipeline/pipes/HsvPipe.java | 1 - .../vision/pipeline/pipes/OutputMatPipe.java | 9 +- .../vision/pipeline/pipes/RotateFlipPipe.java | 10 +- .../vision/pipeline/pipes/SolvePNPPipe.java | 936 +++++++++--------- .../pipeline/pipes/SortContoursPipe.java | 45 +- .../pipeline/pipes/SpeckleRejectPipe.java | 5 +- .../_2/web/RequestHandler.java | 52 +- .../com/chameleonvision/_2/web/Server.java | 45 +- .../chameleonvision/_2/web/SocketHandler.java | 336 ++++--- .../CameraCalibrationCoefficients.java | 46 + .../common/calibration/JsonMat.java | 92 ++ .../networktables/NetworkTablesManager.java | 16 +- .../common/logging/DebugLogger.java | 20 - .../common/networking/LinuxNetworking.java | 7 +- .../common/scripting/ScriptEvent.java | 12 +- .../common/scripting/ScriptManager.java | 7 +- .../common/util/ReflectionUtils.java | 42 + .../common/util/TestUtils.java | 123 +++ .../common/util/file/FileUtils.java | 13 +- .../common/util/math/MathUtils.java | 4 + .../common/util/numbers/DoubleCouple.java | 15 + .../common/util/numbers/NumberCouple.java | 4 +- .../camera/CaptureStaticProperties.java | 49 - .../common/vision/frame/Frame.java | 37 +- .../common/vision/frame/FrameDivisor.java | 14 + .../common/vision/frame/FrameProvider.java | 2 - .../vision/frame/FrameStaticProperties.java | 3 + .../frame/consumer/DummyFrameConsumer.java | 11 + .../frame/provider/FileFrameProvider.java | 8 +- .../frame/provider/NetworkFrameProvider.java | 6 - .../frame/provider/USBFrameProvider.java | 6 - .../common/vision/opencv/CVMat.java | 44 + .../common/vision/opencv/CVShape.java | 60 ++ .../common/vision/opencv/Contour.java | 112 ++- .../vision/opencv/ContourGroupingMode.java | 12 + .../opencv/ContourIntersectionDirection.java | 9 + .../common/vision/opencv/ContourShape.java | 15 + .../common/vision/opencv/ContourSortMode.java | 29 + .../common/vision/opencv/DualMat.java | 8 + .../common/vision/opencv/Releasable.java | 5 + .../vision/{pipeline => pipe}/CVPipe.java | 8 +- .../common/vision/pipe/CVPipeResult.java | 6 + .../common/vision/pipe/ImageFlipMode.java | 14 + .../common/vision/pipe/ImageRotationMode.java | 18 + .../pipe => pipe/impl}/BlurPipe.java | 4 +- .../impl}/Collect2dTargetsPipe.java | 43 +- .../vision/pipe/impl/CornerDetectionPipe.java | 213 ++++ .../impl}/Draw2dContoursPipe.java | 24 +- .../vision/pipe/impl/Draw2dCrosshairPipe.java | 62 ++ .../vision/pipe/impl/Draw3dTargetsPipe.java | 122 +++ .../pipe/impl/DrawCornerDetectionPipe.java | 32 + .../pipe => pipe/impl}/ErodeDilatePipe.java | 4 +- .../impl}/FilterContoursPipe.java | 12 +- .../pipe => pipe/impl}/FindContoursPipe.java | 4 +- .../vision/pipe/impl/FindShapesPipe.java | 41 + .../pipe => pipe/impl}/GroupContoursPipe.java | 43 +- .../{pipeline/pipe => pipe/impl}/HSVPipe.java | 10 +- .../pipe => pipe/impl}/OutputMatPipe.java | 17 +- .../pipe => pipe/impl}/ResizeImagePipe.java | 26 +- .../pipe => pipe/impl}/RotateImagePipe.java | 28 +- .../common/vision/pipe/impl/SolvePNPPipe.java | 139 +++ .../vision/pipe/impl/SortContoursPipe.java | 64 ++ .../pipe => pipe/impl}/SpeckleRejectPipe.java | 4 +- .../common/vision/pipeline/CVPipeline.java | 24 + .../vision/pipeline/CVPipelineResult.java | 40 + .../vision/pipeline/CVPipelineSettings.java | 19 + .../pipeline/Calibration3dPipeline.java | 3 + .../vision/pipeline/ColoredShapePipeline.java | 16 + .../ColoredShapePipelineSettings.java | 7 + .../vision/pipeline/DriverModePipeline.java | 57 ++ .../pipeline/DriverModePipelineResult.java | 10 + .../pipeline/DriverModePipelineSettings.java | 9 + .../common/vision/pipeline/DummyPipeline.java | 31 - .../common/vision/pipeline/PipeResult.java | 6 - .../common/vision/pipeline/PipelineType.java | 17 + .../vision/pipeline/ReflectivePipeline.java | 228 +++++ .../pipeline/ReflectivePipelineSettings.java | 73 ++ .../pipeline/pipe/Draw2dCrosshairPipe.java | 56 -- .../pipeline/pipe/SortContoursPipe.java | 84 -- .../common/vision/target/PotentialTarget.java | 27 +- .../vision/target/RobotOffsetPointMode.java | 7 + .../common/vision/target/TargetModel.java | 75 ++ .../vision/target/TargetOffsetPointEdge.java | 9 + .../vision/target/TargetOrientation.java | 6 + .../common/vision/target/TrackedTarget.java | 110 +- .../wpi/first/wpilibj/geometry/Pose2d.java | 61 +- .../first/wpilibj/geometry/Rotation2d.java | 57 +- .../first/wpilibj/geometry/Transform2d.java | 16 +- .../first/wpilibj/geometry/Translation2d.java | 64 +- .../src/main/resources/log4j.properties | 1 + .../pipeline/ReflectivePipelineTest.java | 125 +++ .../common/vision/pipeline/SolvePNPTest.java | 197 ++++ .../resources/calibration/lifecam320p.json | 34 + .../resources/calibration/lifecam640p.json | 34 + .../2019/WPI}/CargoAngledDark48in.jpg | Bin .../2019/WPI}/CargoSideStraightDark36in.jpg | Bin .../2019/WPI}/CargoSideStraightDark60in.jpg | Bin .../2019/WPI}/CargoSideStraightDark72in.jpg | Bin .../WPI}/CargoSideStraightPanelDark36in.jpg | Bin .../2019/WPI}/CargoStraightDark19in.jpg | Bin .../2019/WPI}/CargoStraightDark24in.jpg | Bin .../2019/WPI}/CargoStraightDark48in.jpg | Bin .../2019/WPI}/CargoStraightDark72in.jpg | Bin .../WPI/CargoStraightDark72in_HighRes.jpg | Bin 0 -> 244754 bytes .../2019/WPI}/CargoStraightDark90in.jpg | Bin .../testimages/2019/WPI}/LoadingAngle36in.jpg | Bin .../2019/WPI}/LoadingAngleDark36in.jpg | Bin .../2019/WPI}/LoadingAngleDark60in.jpg | Bin .../2019/WPI}/LoadingAngleDark96in.jpg | Bin .../2019/WPI}/LoadingStraight108in.jpg | Bin .../2019/WPI}/LoadingStraight36in.jpg | Bin .../2019/WPI}/LoadingStraightDark108in.jpg | Bin .../2019/WPI}/LoadingStraightDark10in.jpg | Bin .../2019/WPI}/LoadingStraightDark13in.jpg | Bin .../2019/WPI}/LoadingStraightDark21in.jpg | Bin .../2019/WPI}/LoadingStraightDark36in.jpg | Bin .../2019/WPI}/LoadingStraightDark48in.jpg | Bin .../2019/WPI}/LoadingStraightDark60in.jpg | Bin .../2019/WPI}/LoadingStraightDark84in.jpg | Bin .../2019/WPI}/LoadingStraightDark9in.jpg | Bin .../2019/WPI}/RocketBallStraightDark19in.jpg | Bin .../2019/WPI}/RocketBallStraightDark24in.jpg | Bin .../2019/WPI}/RocketBallStraightDark29in.jpg | Bin .../2019/WPI}/RocketBallStraightDark48in.jpg | Bin .../2019/WPI}/RocketPanelAngleDark48in.jpg | Bin .../2019/WPI}/RocketPanelAngleDark60in.jpg | Bin .../2019/WPI}/RocketPanelAngleDark84in.jpg | Bin .../2019/WPI}/RocketPanelStraight48in.jpg | Bin .../2019/WPI}/RocketPanelStraight84in.jpg | Bin .../2019/WPI}/RocketPanelStraightDark12in.jpg | Bin .../2019/WPI}/RocketPanelStraightDark16in.jpg | Bin .../2019/WPI}/RocketPanelStraightDark24in.jpg | Bin .../2019/WPI}/RocketPanelStraightDark36in.jpg | Bin .../2019/WPI}/RocketPanelStraightDark48in.jpg | Bin .../2019/WPI}/RocketPanelStraightDark60in.jpg | Bin .../2019/WPI}/RocketPanelStraightDark72in.jpg | Bin .../2019/WPI}/RocketPanelStraightDark96in.jpg | Bin .../2019/WPI}/RocketStraightDark96in.jpg | Bin .../resources/testimages/2019/WPI}/info.txt | 0 .../2020/WPI}/BlueGoal-060in-Center.jpg | Bin .../2020/WPI}/BlueGoal-084in-Center.jpg | Bin .../2020/WPI}/BlueGoal-108in-Center.jpg | Bin .../2020/WPI}/BlueGoal-132in-Center.jpg | Bin .../2020/WPI}/BlueGoal-156in-Center.jpg | Bin .../2020/WPI}/BlueGoal-156in-Left.jpg | Bin .../2020/WPI}/BlueGoal-180in-Center.jpg | Bin .../2020/WPI/BlueGoal-224in-Left.jpg | Bin .../WPI}/BlueGoal-228in-ProtectedZone.jpg | Bin .../WPI}/BlueGoal-330in-ProtectedZone.jpg | Bin .../2020/WPI}/BlueGoal-Far-ProtectedZone.jpg | Bin .../2020/WPI}/RedLoading-016in-Down.jpg | Bin .../2020/WPI}/RedLoading-030in-Down.jpg | Bin .../2020/WPI}/RedLoading-048in-Down.jpg | Bin .../testimages/2020/WPI}/RedLoading-048in.jpg | Bin .../testimages/2020/WPI}/RedLoading-060in.jpg | Bin .../testimages/2020/WPI}/RedLoading-084in.jpg | Bin .../testimages/2020/WPI}/RedLoading-108in.jpg | Bin .../resources/testimages/2020/WPI/info.txt | 3 + testimages/2020/image.png | Bin 19136 -> 0 bytes 214 files changed, 4394 insertions(+), 1937 deletions(-) create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/calibration/CameraCalibrationCoefficients.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/calibration/JsonMat.java delete mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/logging/DebugLogger.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/util/ReflectionUtils.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/util/TestUtils.java delete mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/camera/CaptureStaticProperties.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/FrameDivisor.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/consumer/DummyFrameConsumer.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/CVMat.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/CVShape.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/ContourGroupingMode.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/ContourIntersectionDirection.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/ContourShape.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/ContourSortMode.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/DualMat.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/Releasable.java rename chameleon-server/src/main/java/com/chameleonvision/common/vision/{pipeline => pipe}/CVPipe.java (78%) create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/CVPipeResult.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/ImageFlipMode.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/ImageRotationMode.java rename chameleon-server/src/main/java/com/chameleonvision/common/vision/{pipeline/pipe => pipe/impl}/BlurPipe.java (90%) rename chameleon-server/src/main/java/com/chameleonvision/common/vision/{pipeline/pipe => pipe/impl}/Collect2dTargetsPipe.java (59%) create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/CornerDetectionPipe.java rename chameleon-server/src/main/java/com/chameleonvision/common/vision/{pipeline/pipe => pipe/impl}/Draw2dContoursPipe.java (79%) create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/Draw2dCrosshairPipe.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/Draw3dTargetsPipe.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/DrawCornerDetectionPipe.java rename chameleon-server/src/main/java/com/chameleonvision/common/vision/{pipeline/pipe => pipe/impl}/ErodeDilatePipe.java (90%) rename chameleon-server/src/main/java/com/chameleonvision/common/vision/{pipeline/pipe => pipe/impl}/FilterContoursPipe.java (88%) rename chameleon-server/src/main/java/com/chameleonvision/common/vision/{pipeline/pipe => pipe/impl}/FindContoursPipe.java (88%) create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/FindShapesPipe.java rename chameleon-server/src/main/java/com/chameleonvision/common/vision/{pipeline/pipe => pipe/impl}/GroupContoursPipe.java (56%) rename chameleon-server/src/main/java/com/chameleonvision/common/vision/{pipeline/pipe => pipe/impl}/HSVPipe.java (70%) rename chameleon-server/src/main/java/com/chameleonvision/common/vision/{pipeline/pipe => pipe/impl}/OutputMatPipe.java (63%) rename chameleon-server/src/main/java/com/chameleonvision/common/vision/{pipeline/pipe => pipe/impl}/ResizeImagePipe.java (61%) rename chameleon-server/src/main/java/com/chameleonvision/common/vision/{pipeline/pipe => pipe/impl}/RotateImagePipe.java (57%) create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/SolvePNPPipe.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/SortContoursPipe.java rename chameleon-server/src/main/java/com/chameleonvision/common/vision/{pipeline/pipe => pipe/impl}/SpeckleRejectPipe.java (91%) create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/CVPipeline.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/CVPipelineResult.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/CVPipelineSettings.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/Calibration3dPipeline.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/ColoredShapePipeline.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/ColoredShapePipelineSettings.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/DriverModePipeline.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/DriverModePipelineResult.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/DriverModePipelineSettings.java delete mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/DummyPipeline.java delete mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/PipeResult.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/PipelineType.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/ReflectivePipeline.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/ReflectivePipelineSettings.java delete mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/Draw2dCrosshairPipe.java delete mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/SortContoursPipe.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/target/RobotOffsetPointMode.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/target/TargetModel.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/target/TargetOffsetPointEdge.java create mode 100644 chameleon-server/src/main/java/com/chameleonvision/common/vision/target/TargetOrientation.java create mode 100644 chameleon-server/src/main/resources/log4j.properties create mode 100644 chameleon-server/src/test/java/com/chameleonvision/common/vision/pipeline/ReflectivePipelineTest.java create mode 100644 chameleon-server/src/test/java/com/chameleonvision/common/vision/pipeline/SolvePNPTest.java create mode 100644 chameleon-server/src/test/resources/calibration/lifecam320p.json create mode 100644 chameleon-server/src/test/resources/calibration/lifecam640p.json rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/CargoAngledDark48in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/CargoSideStraightDark36in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/CargoSideStraightDark60in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/CargoSideStraightDark72in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/CargoSideStraightPanelDark36in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/CargoStraightDark19in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/CargoStraightDark24in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/CargoStraightDark48in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/CargoStraightDark72in.jpg (100%) create mode 100644 chameleon-server/src/test/resources/testimages/2019/WPI/CargoStraightDark72in_HighRes.jpg rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/CargoStraightDark90in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/LoadingAngle36in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/LoadingAngleDark36in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/LoadingAngleDark60in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/LoadingAngleDark96in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/LoadingStraight108in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/LoadingStraight36in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/LoadingStraightDark108in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/LoadingStraightDark10in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/LoadingStraightDark13in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/LoadingStraightDark21in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/LoadingStraightDark36in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/LoadingStraightDark48in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/LoadingStraightDark60in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/LoadingStraightDark84in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/LoadingStraightDark9in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/RocketBallStraightDark19in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/RocketBallStraightDark24in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/RocketBallStraightDark29in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/RocketBallStraightDark48in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/RocketPanelAngleDark48in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/RocketPanelAngleDark60in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/RocketPanelAngleDark84in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/RocketPanelStraight48in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/RocketPanelStraight84in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/RocketPanelStraightDark12in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/RocketPanelStraightDark16in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/RocketPanelStraightDark24in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/RocketPanelStraightDark36in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/RocketPanelStraightDark48in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/RocketPanelStraightDark60in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/RocketPanelStraightDark72in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/RocketPanelStraightDark96in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/RocketStraightDark96in.jpg (100%) rename {testimages/2019 => chameleon-server/src/test/resources/testimages/2019/WPI}/info.txt (100%) rename {testimages/2020 => chameleon-server/src/test/resources/testimages/2020/WPI}/BlueGoal-060in-Center.jpg (100%) rename {testimages/2020 => chameleon-server/src/test/resources/testimages/2020/WPI}/BlueGoal-084in-Center.jpg (100%) rename {testimages/2020 => chameleon-server/src/test/resources/testimages/2020/WPI}/BlueGoal-108in-Center.jpg (100%) rename {testimages/2020 => chameleon-server/src/test/resources/testimages/2020/WPI}/BlueGoal-132in-Center.jpg (100%) rename {testimages/2020 => chameleon-server/src/test/resources/testimages/2020/WPI}/BlueGoal-156in-Center.jpg (100%) rename {testimages/2020 => chameleon-server/src/test/resources/testimages/2020/WPI}/BlueGoal-156in-Left.jpg (100%) rename {testimages/2020 => chameleon-server/src/test/resources/testimages/2020/WPI}/BlueGoal-180in-Center.jpg (100%) rename testimages/2020/BlueGoal-224in-Center.jpg => chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-224in-Left.jpg (100%) rename {testimages/2020 => chameleon-server/src/test/resources/testimages/2020/WPI}/BlueGoal-228in-ProtectedZone.jpg (100%) rename {testimages/2020 => chameleon-server/src/test/resources/testimages/2020/WPI}/BlueGoal-330in-ProtectedZone.jpg (100%) rename {testimages/2020 => chameleon-server/src/test/resources/testimages/2020/WPI}/BlueGoal-Far-ProtectedZone.jpg (100%) rename {testimages/2020 => chameleon-server/src/test/resources/testimages/2020/WPI}/RedLoading-016in-Down.jpg (100%) rename {testimages/2020 => chameleon-server/src/test/resources/testimages/2020/WPI}/RedLoading-030in-Down.jpg (100%) rename {testimages/2020 => chameleon-server/src/test/resources/testimages/2020/WPI}/RedLoading-048in-Down.jpg (100%) rename {testimages/2020 => chameleon-server/src/test/resources/testimages/2020/WPI}/RedLoading-048in.jpg (100%) rename {testimages/2020 => chameleon-server/src/test/resources/testimages/2020/WPI}/RedLoading-060in.jpg (100%) rename {testimages/2020 => chameleon-server/src/test/resources/testimages/2020/WPI}/RedLoading-084in.jpg (100%) rename {testimages/2020 => chameleon-server/src/test/resources/testimages/2020/WPI}/RedLoading-108in.jpg (100%) create mode 100644 chameleon-server/src/test/resources/testimages/2020/WPI/info.txt delete mode 100644 testimages/2020/image.png diff --git a/.gitignore b/.gitignore index 06e0ca5f7..beb360ffd 100644 --- a/.gitignore +++ b/.gitignore @@ -122,3 +122,4 @@ New client/chameleon-client/* .DS_Store # *.iml chameleon-server/build +chameleon-server/chameleon-vision (1).iml diff --git a/chameleon-server/build.gradle b/chameleon-server/build.gradle index 26e4ee0e3..d8ee1a321 100644 --- a/chameleon-server/build.gradle +++ b/chameleon-server/build.gradle @@ -68,6 +68,8 @@ dependencies { compile "edu.wpi.first.thirdparty.frc2020.opencv:opencv-jni:$openCVVersion:osxx86-64" compile "edu.wpi.first.thirdparty.frc2020.opencv:opencv-jni:$openCVVersion:windowsx86-64" + testCompile "ch.qos.logback:logback-classic:0.9.26" + // javacv (ew) // def withoutJunk = { // exclude group: 'org.bytedeco', module: 'artoolkitplus' @@ -104,7 +106,6 @@ dependencies { sourceSets { main { java { - srcDir 'src' exclude '**/_2/**' } } @@ -113,9 +114,6 @@ sourceSets { test { test { useJUnitPlatform() - testLogging { - events "passed", "skipped", "failed" - } } } @@ -125,5 +123,6 @@ spotless { paddedCell() indentWithTabs(2) indentWithSpaces(4) + removeUnusedImports() } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/Main.java b/chameleon-server/src/main/java/com/chameleonvision/_2/Main.java index 6719f055d..984945bce 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/Main.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/Main.java @@ -1,5 +1,7 @@ package com.chameleonvision._2; +import static com.chameleonvision.common.util.Platform.CurrentPlatform; + import com.chameleonvision._2.config.ConfigManager; import com.chameleonvision._2.vision.VisionManager; import com.chameleonvision._2.web.Server; @@ -11,15 +13,14 @@ import com.chameleonvision.common.util.Platform; import com.chameleonvision.common.util.math.IPUtils; import edu.wpi.cscore.CameraServerCvJNI; import edu.wpi.cscore.CameraServerJNI; - import java.io.IOException; -import static com.chameleonvision.common.util.Platform.CurrentPlatform; - public class Main { private static final String NT_SERVERMODE_KEY = "--nt-servermode"; // no args for this setting - private static final String NT_CLIENTMODESERVER_KEY = "--nt-client-server"; // expects String representing an IP address (hostnames will be rejected!) + private static final String NT_CLIENTMODESERVER_KEY = + "--nt-client-server"; // expects String representing an IP address (hostnames will be + // rejected!) private static final String NETWORK_MANAGE_KEY = "--unmanage-network"; // no args for this setting private static final String IGNORE_ROOT_KEY = "--ignore-root"; // no args for this setting private static final String TEST_MODE_KEY = "--cv-development"; @@ -44,14 +45,18 @@ public class Main { case NT_CLIENTMODESERVER_KEY: var potentialValue = args[i + 1]; // ensures this "value" isnt null, blank, nor another argument - if (potentialValue != null && !potentialValue.isBlank() && !potentialValue.startsWith("-") & !potentialValue.startsWith("--")) { + if (potentialValue != null + && !potentialValue.isBlank() + && !potentialValue.startsWith("-") & !potentialValue.startsWith("--")) { value = potentialValue.toLowerCase(); } i++; // increment to skip an 'arg' next go-around of for loop, as that would be this value break; case UI_PORT_KEY: var potentialPort = args[i + 1]; - if (potentialPort != null && !potentialPort.isBlank() && !potentialPort.startsWith("-") & !potentialPort.startsWith("--")) { + if (potentialPort != null + && !potentialPort.isBlank() + && !potentialPort.startsWith("-") & !potentialPort.startsWith("--")) { value = potentialPort; } i++; @@ -81,7 +86,8 @@ public class Main { continue; } } - System.err.println("Argument for NT Server Host was invalid, defaulting to team number host"); + System.err.println( + "Argument for NT Server Host was invalid, defaulting to team number host"); break; case NETWORK_MANAGE_KEY: manageNetwork = false; @@ -96,7 +102,7 @@ public class Main { if (value != null) { try { uiPort = Integer.parseInt(value); - } catch (NumberFormatException e){ + } catch (NumberFormatException e) { System.err.println("ui Port was not a valid number using port 5800"); } } @@ -107,10 +113,13 @@ public class Main { public static void main(String[] args) { - Runtime.getRuntime().addShutdownHook(new Thread(() -> ScriptManager.queueEvent(ScriptEventType.kProgramExit))); + Runtime.getRuntime() + .addShutdownHook(new Thread(() -> ScriptManager.queueEvent(ScriptEventType.kProgramExit))); if (CurrentPlatform.equals(Platform.UNSUPPORTED)) { - System.err.printf("Sorry, this platform is not supported. Give these details to the developers.\n%s\n", CurrentPlatform.toString()); + System.err.printf( + "Sorry, this platform is not supported. Give these details to the developers.\n%s\n", + CurrentPlatform.toString()); return; } else { System.out.printf("Starting Chameleon Vision on platform %s\n", CurrentPlatform.toString()); @@ -135,7 +144,8 @@ public class Main { CameraServerCvJNI.forceLoad(); } catch (UnsatisfiedLinkError | IOException e) { if (CurrentPlatform.isWindows()) { - System.err.println("Try to download the VC++ Redistributable, https://aka.ms/vs/16/release/vc_redist.x64.exe"); + System.err.println( + "Try to download the VC++ Redistributable, https://aka.ms/vs/16/release/vc_redist.x64.exe"); } throw new RuntimeException("Failed to load JNI Libraries!"); } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/config/CameraCalibrationConfig.java b/chameleon-server/src/main/java/com/chameleonvision/_2/config/CameraCalibrationConfig.java index c2326d1ea..9a7303f63 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/config/CameraCalibrationConfig.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/config/CameraCalibrationConfig.java @@ -8,14 +8,19 @@ import org.opencv.core.Mat; import org.opencv.core.MatOfDouble; import org.opencv.core.Size; -/** - * A class that holds a camera matrix and distortion coefficients for a given resolution - */ +/** A class that holds a camera matrix and distortion coefficients for a given resolution */ public class CameraCalibrationConfig { - @JsonProperty("resolution") public final Size resolution; - @JsonProperty("cameraMatrix") public final JsonMat cameraMatrix; - @JsonProperty("distortionCoeffs") public final JsonMat distortionCoeffs; - @JsonProperty("squareSize") public final double squareSize; + @JsonProperty("resolution") + public final Size resolution; + + @JsonProperty("cameraMatrix") + public final JsonMat cameraMatrix; + + @JsonProperty("distortionCoeffs") + public final JsonMat distortionCoeffs; + + @JsonProperty("squareSize") + public final double squareSize; @JsonCreator public CameraCalibrationConfig( @@ -29,7 +34,8 @@ public class CameraCalibrationConfig { this.squareSize = squareSize; } - public CameraCalibrationConfig(Size resolution, Mat cameraMatrix, Mat distortionCoeffs, double squareSize) { + public CameraCalibrationConfig( + Size resolution, Mat cameraMatrix, Mat distortionCoeffs, double squareSize) { this.resolution = resolution; this.cameraMatrix = JsonMat.fromMat(cameraMatrix); this.distortionCoeffs = JsonMat.fromMat(distortionCoeffs); @@ -49,16 +55,15 @@ public class CameraCalibrationConfig { cameraMatrix = config.cameraMatrix.data; distortionCoeffs = config.distortionCoeffs.data; } - } @JsonIgnore public Mat getCameraMatrixAsMat() { - return cameraMatrix.toMat(); + return cameraMatrix.getAsMat(); } @JsonIgnore public MatOfDouble getDistortionCoeffsAsMat() { - return new MatOfDouble(distortionCoeffs.toMat()); + return new MatOfDouble(distortionCoeffs.getAsMat()); } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/config/CameraConfig.java b/chameleon-server/src/main/java/com/chameleonvision/_2/config/CameraConfig.java index 21586c33d..eef4da9a2 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/config/CameraConfig.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/config/CameraConfig.java @@ -1,9 +1,8 @@ package com.chameleonvision._2.config; +import com.chameleonvision._2.vision.pipeline.CVPipelineSettings; import com.chameleonvision.common.util.file.FileUtils; import com.chameleonvision.common.util.file.JacksonUtils; -import com.chameleonvision._2.vision.pipeline.CVPipelineSettings; - import java.io.File; import java.io.IOException; import java.nio.file.Files; @@ -15,7 +14,8 @@ import java.util.Objects; public class CameraConfig { - private static final Path camerasConfigFolderPath = Path.of(ConfigManager.SettingsPath.toString(), "cameras"); + private static final Path camerasConfigFolderPath = + Path.of(ConfigManager.SettingsPath.toString(), "cameras"); private final CameraJsonConfig preliminaryConfig; private final Path configFolderPath; @@ -45,7 +45,8 @@ public class CameraConfig { checkCalibration(); pipelineConfig.check(); - return new FullCameraConfiguration(loadConfig(), pipelineConfig.load(), loadDriverMode(), loadCalibration(), this); + return new FullCameraConfiguration( + loadConfig(), pipelineConfig.load(), loadDriverMode(), loadCalibration(), this); } private CameraJsonConfig loadConfig() { @@ -53,7 +54,8 @@ public class CameraConfig { try { config = JacksonUtils.deserialize(configPath, CameraJsonConfig.class); } catch (IOException e) { - System.err.printf("Failed to load camera config: %s - using default.\n", configPath.toString()); + System.err.printf( + "Failed to load camera config: %s - using default.\n", configPath.toString()); } return config; } @@ -75,7 +77,10 @@ public class CameraConfig { private List loadCalibration() { List calibrations = new ArrayList<>(); try { - calibrations = List.of(Objects.requireNonNull(JacksonUtils.deserialize(calibrationPath, CameraCalibrationConfig[].class))); + calibrations = + List.of( + Objects.requireNonNull( + JacksonUtils.deserialize(calibrationPath, CameraCalibrationConfig[].class))); } catch (Exception e) { System.err.println("Failed to load camera calibration: " + driverModePath.toString()); } @@ -104,7 +109,6 @@ public class CameraConfig { } } - public void saveCalibration(List cal) { CameraCalibrationConfig[] configs = cal.toArray(new CameraCalibrationConfig[0]); try { @@ -119,7 +123,8 @@ public class CameraConfig { if (!configFolderExists()) { try { if (!(new File(configFolderPath.toUri()).mkdirs())) { - System.err.println("Failed to create camera config folder: " + configFolderPath.toString()); + System.err.println( + "Failed to create camera config folder: " + configFolderPath.toString()); } FileUtils.setFilePerms(configFolderPath); } catch (Exception e) { @@ -158,7 +163,8 @@ public class CameraConfig { List calibrations = new ArrayList<>(); JacksonUtils.serializer(calibrationPath, calibrations.toArray(), true); } catch (IOException e) { - System.err.println("Failed to create camera calibration file: " + calibrationPath.toString()); + System.err.println( + "Failed to create camera calibration file: " + calibrationPath.toString()); } } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/config/CameraJsonConfig.java b/chameleon-server/src/main/java/com/chameleonvision/_2/config/CameraJsonConfig.java index 9ca60c571..4ac2ee57f 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/config/CameraJsonConfig.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/config/CameraJsonConfig.java @@ -48,6 +48,13 @@ public class CameraJsonConfig { int videomode = camProps.getCurrentVideoModeIndex(); StreamDivisor streamDivisor = process.cameraStreamer.getDivisor(); double tilt = process.getCamera().getProperties().getTilt().getDegrees(); - return new CameraJsonConfig(camProps.getFOV(), camProps.path, camProps.name, camProps.getNickname(), videomode, streamDivisor, tilt); + return new CameraJsonConfig( + camProps.getFOV(), + camProps.path, + camProps.name, + camProps.getNickname(), + videomode, + streamDivisor, + tilt); } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/config/ConfigManager.java b/chameleon-server/src/main/java/com/chameleonvision/_2/config/ConfigManager.java index 414afcefd..2574c21c4 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/config/ConfigManager.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/config/ConfigManager.java @@ -6,7 +6,6 @@ import com.chameleonvision.common.util.Platform; import com.chameleonvision.common.util.ShellExec; import com.chameleonvision.common.util.file.FileUtils; import com.chameleonvision.common.util.file.JacksonUtils; - import java.io.File; import java.io.IOException; import java.nio.file.Files; @@ -17,10 +16,10 @@ import java.util.LinkedHashMap; import java.util.List; public class ConfigManager { - private ConfigManager() { - } + private ConfigManager() {} - public static final Path SettingsPath = Paths.get(ProgramDirectoryUtilities.getProgramDirectory(), "settings"); + public static final Path SettingsPath = + Paths.get(ProgramDirectoryUtilities.getProgramDirectory(), "settings"); private static final Path settingsFilePath = Paths.get(SettingsPath.toString(), "settings.json"); private static final LinkedHashMap cameraConfigs = new LinkedHashMap<>(); @@ -46,14 +45,14 @@ public class ConfigManager { new ShellExec().executeBashCommand("sudo chmod -R 0777 " + SettingsPath.toString()); } } catch (IOException e) { - if (!(e instanceof java.nio.file.FileAlreadyExistsException)) - e.printStackTrace(); + if (!(e instanceof java.nio.file.FileAlreadyExistsException)) e.printStackTrace(); } } } private static void checkSettingsFile() { - boolean settingsFileEmpty = settingsFileExists() && new File(settingsFilePath.toString()).length() == 0; + boolean settingsFileEmpty = + settingsFileExists() && new File(settingsFilePath.toString()).length() == 0; if (settingsFileEmpty || !settingsFileExists()) { try { JacksonUtils.serializer(settingsFilePath, settings, true); @@ -91,7 +90,8 @@ public class ConfigManager { saveSettingsFile(); } - public static List initializeCameras(List preliminaryConfigs) { + public static List initializeCameras( + List preliminaryConfigs) { List configList = new ArrayList<>(); checkSettingsFolder(); diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/config/FullCameraConfiguration.java b/chameleon-server/src/main/java/com/chameleonvision/_2/config/FullCameraConfiguration.java index b4c66e083..22d11c6d8 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/config/FullCameraConfiguration.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/config/FullCameraConfiguration.java @@ -1,7 +1,6 @@ package com.chameleonvision._2.config; import com.chameleonvision._2.vision.pipeline.CVPipelineSettings; - import java.util.List; public class FullCameraConfiguration { @@ -11,7 +10,12 @@ public class FullCameraConfiguration { public final List calibration; public final CameraConfig fileConfig; - FullCameraConfiguration(CameraJsonConfig cameraConfig, List pipelines, CVPipelineSettings driverMode, List calibration, CameraConfig fileConfig) { + FullCameraConfiguration( + CameraJsonConfig cameraConfig, + List pipelines, + CVPipelineSettings driverMode, + List calibration, + CameraConfig fileConfig) { this.cameraConfig = cameraConfig; this.pipelines = pipelines; this.driverMode = driverMode; diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/config/GeneralSettings.java b/chameleon-server/src/main/java/com/chameleonvision/_2/config/GeneralSettings.java index 647b65f71..9b28492bb 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/config/GeneralSettings.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/config/GeneralSettings.java @@ -3,12 +3,12 @@ package com.chameleonvision._2.config; import com.chameleonvision.common.networking.NetworkMode; public class GeneralSettings { - public int teamNumber = 1577; - public NetworkMode connectionType = NetworkMode.DHCP; - public String ip = ""; - public String gateway = ""; - public String netmask = ""; - public String hostname = "Chameleon-vision"; - public String currentCamera = ""; - public Integer currentPipeline = null; + public int teamNumber = 1577; + public NetworkMode connectionType = NetworkMode.DHCP; + public String ip = ""; + public String gateway = ""; + public String netmask = ""; + public String hostname = "Chameleon-vision"; + public String currentCamera = ""; + public Integer currentPipeline = null; } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/config/JsonMat.java b/chameleon-server/src/main/java/com/chameleonvision/_2/config/JsonMat.java index 69b8341ce..8ec5e3013 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/config/JsonMat.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/config/JsonMat.java @@ -1,18 +1,22 @@ package com.chameleonvision._2.config; +import com.chameleonvision.common.vision.opencv.Releasable; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Arrays; import org.opencv.core.CvType; import org.opencv.core.Mat; +import org.opencv.core.MatOfDouble; -import java.util.Arrays; - - -public class JsonMat { +public class JsonMat implements Releasable { public final int rows; public final int cols; public final int type; public final double[] data; + @JsonIgnore private Mat wrappedMat; + private MatOfDouble wrappedMatOfDouble; + public JsonMat(int rows, int cols, double[] data) { this(rows, cols, CvType.CV_64FC1, data); } @@ -28,10 +32,6 @@ public class JsonMat { this.data = data; } - public Mat toMat() { - return toMat(this); - } - private static boolean isCameraMatrixMat(Mat mat) { return mat.type() == CvType.CV_64FC1 && mat.cols() == 3 && mat.rows() == 3; } @@ -44,10 +44,11 @@ public class JsonMat { return isDistortionCoeffsMat(mat) || isCameraMatrixMat(mat); } + @JsonIgnore public static double[] getDataFromMat(Mat mat) { if (!isCalibrationMat(mat)) return null; - double[] data = new double[(int)(mat.total()*mat.elemSize())]; + double[] data = new double[(int) (mat.total() * mat.elemSize())]; mat.get(0, 0, data); int dataLen = -1; @@ -64,11 +65,28 @@ public class JsonMat { return new JsonMat(mat.rows(), mat.cols(), getDataFromMat(mat)); } - public static Mat toMat(JsonMat jsonMat) { - if (jsonMat.type != CvType.CV_64FC1) return null; + @JsonIgnore + public Mat getAsMat() { + if (this.type != CvType.CV_64FC1) return null; - Mat retMat = new Mat(jsonMat.rows, jsonMat.cols, jsonMat.type); - retMat.put(0, 0, jsonMat.data); - return retMat; + if (wrappedMat == null) { + this.wrappedMat = new Mat(this.rows, this.cols, this.type); + this.wrappedMat.put(0, 0, this.data); + } + return this.wrappedMat; + } + + @JsonIgnore + public MatOfDouble getAsMatOfDouble() { + if (this.wrappedMatOfDouble == null) { + this.wrappedMatOfDouble = new MatOfDouble(); + getAsMat().convertTo(wrappedMatOfDouble, CvType.CV_64F); + } + return this.wrappedMatOfDouble; + } + + @Override + public void release() { + getAsMat().release(); } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/config/PipelineConfig.java b/chameleon-server/src/main/java/com/chameleonvision/_2/config/PipelineConfig.java index fdc95042b..ae48c7917 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/config/PipelineConfig.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/config/PipelineConfig.java @@ -6,7 +6,6 @@ import com.chameleonvision._2.vision.pipeline.CVPipelineSettings; import com.chameleonvision._2.vision.pipeline.impl.StandardCVPipelineSettings; import com.chameleonvision.common.util.file.FileUtils; import com.chameleonvision.common.util.file.JacksonUtils; - import java.io.File; import java.io.IOException; import java.nio.file.Files; @@ -20,10 +19,10 @@ public class PipelineConfig { private final CameraConfig cameraConfig; /** - * Construct a new PipelineConfig - * - * @param cameraConfig the CameraConfig (parent folder, kinda?) - */ + * Construct a new PipelineConfig + * + * @param cameraConfig the CameraConfig (parent folder, kinda?) + */ PipelineConfig(CameraConfig cameraConfig) { this.cameraConfig = cameraConfig; } @@ -76,7 +75,12 @@ public class PipelineConfig { if (settings instanceof StandardCVPipelineSettings) { try { - JacksonUtils.serialize(path, (StandardCVPipelineSettings) settings, StandardCVPipelineSettings.class, new StandardCVPipelineSettingsSerializer(), true); + JacksonUtils.serialize( + path, + (StandardCVPipelineSettings) settings, + StandardCVPipelineSettings.class, + new StandardCVPipelineSettingsSerializer(), + true); FileUtils.setFilePerms(path); } catch (IOException e) { e.printStackTrace(); @@ -131,7 +135,11 @@ public class PipelineConfig { } else { for (File pipelineFile : pipelineFiles) { try { - var pipe = JacksonUtils.deserialize(Paths.get(pipelineFile.getPath()), StandardCVPipelineSettings.class, new StandardCVPipelineSettingsDeserializer()); + var pipe = + JacksonUtils.deserialize( + Paths.get(pipelineFile.getPath()), + StandardCVPipelineSettings.class, + new StandardCVPipelineSettingsDeserializer()); deserializedList.add(pipe); } catch (IOException e) { System.err.println("couldn't load cvpipeline2d"); diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/config/serializers/BaseDeserializer.java b/chameleon-server/src/main/java/com/chameleonvision/_2/config/serializers/BaseDeserializer.java index 863e4ba11..9de4c304a 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/config/serializers/BaseDeserializer.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/config/serializers/BaseDeserializer.java @@ -8,12 +8,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import com.fasterxml.jackson.databind.type.CollectionType; import com.fasterxml.jackson.databind.type.TypeFactory; -import org.opencv.core.MatOfPoint3f; -import org.opencv.core.Point3; - import java.io.IOException; import java.util.ArrayList; import java.util.List; +import org.opencv.core.MatOfPoint3f; +import org.opencv.core.Point3; public abstract class BaseDeserializer extends StdDeserializer { protected BaseDeserializer(Class vc) { @@ -22,14 +21,18 @@ public abstract class BaseDeserializer extends StdDeserializer { JsonNode baseNode; - private static final CollectionType numberListColType = TypeFactory.defaultInstance().constructCollectionType(List.class, Number.class); - private CollectionType pointListColType = TypeFactory.defaultInstance().constructCollectionType(List.class, Object.class); + private static final CollectionType numberListColType = + TypeFactory.defaultInstance().constructCollectionType(List.class, Number.class); + private CollectionType pointListColType = + TypeFactory.defaultInstance().constructCollectionType(List.class, Object.class); private static final ObjectMapper mapper = new ObjectMapper(); + private static boolean nodeGood(JsonNode node) { return node != null && !node.toString().equals(""); } - IntegerCouple getNumberCouple(String name, IntegerCouple defaultValue) throws JsonProcessingException { + IntegerCouple getNumberCouple(String name, IntegerCouple defaultValue) + throws JsonProcessingException { JsonNode node = baseNode.get(name); if (nodeGood(node)) { @@ -39,7 +42,8 @@ public abstract class BaseDeserializer extends StdDeserializer { return defaultValue; } - DoubleCouple getNumberCouple(String name, DoubleCouple defaultValue) throws JsonProcessingException { + DoubleCouple getNumberCouple(String name, DoubleCouple defaultValue) + throws JsonProcessingException { JsonNode node = baseNode.get(name); if (nodeGood(node)) { @@ -49,7 +53,8 @@ public abstract class BaseDeserializer extends StdDeserializer { return defaultValue; } - List getNumberList(String name, List defaultValue) throws JsonProcessingException { + List getNumberList(String name, List defaultValue) + throws JsonProcessingException { JsonNode node = baseNode.get(name); if (nodeGood(node)) { @@ -69,7 +74,7 @@ public abstract class BaseDeserializer extends StdDeserializer { } int getInt(String name, int defaultValue) { - return (int) getDouble(name, defaultValue); + return (int) getDouble(name, defaultValue); } double getDouble(String name, double defaultValue) { @@ -92,7 +97,8 @@ public abstract class BaseDeserializer extends StdDeserializer { return defaultValue; } - > E getEnum(String name, Class enumClass, E defaultValue) throws IOException { + > E getEnum(String name, Class enumClass, E defaultValue) + throws IOException { JsonNode node = baseNode.get(name); if (nodeGood(node)) { @@ -108,12 +114,14 @@ public abstract class BaseDeserializer extends StdDeserializer { return defaultValue; } - MatOfPoint3f getMatOfPoint3f(String name, MatOfPoint3f defaultValue) throws JsonProcessingException { + + MatOfPoint3f getMatOfPoint3f(String name, MatOfPoint3f defaultValue) + throws JsonProcessingException { JsonNode node = baseNode.get(name); - if (nodeGood(node)){ + if (nodeGood(node)) { List> numberList = mapper.readValue(node.toString(), pointListColType); List point3List = new ArrayList<>(); - for (List tmp : numberList){ + for (List tmp : numberList) { Point3 p = new Point3(); p.x = tmp.get(0).doubleValue(); p.y = tmp.get(1).doubleValue(); diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/config/serializers/BaseSerializer.java b/chameleon-server/src/main/java/com/chameleonvision/_2/config/serializers/BaseSerializer.java index 6826332ca..fcb3f9e9d 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/config/serializers/BaseSerializer.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/config/serializers/BaseSerializer.java @@ -3,11 +3,10 @@ package com.chameleonvision._2.config.serializers; import com.chameleonvision.common.util.numbers.NumberCouple; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.ser.std.StdSerializer; -import org.opencv.core.MatOfPoint3f; -import org.opencv.core.Point3; - import java.io.IOException; import java.util.List; +import org.opencv.core.MatOfPoint3f; +import org.opencv.core.Point3; public abstract class BaseSerializer extends StdSerializer { protected BaseSerializer(Class t) { @@ -16,7 +15,8 @@ public abstract class BaseSerializer extends StdSerializer { JsonGenerator generator; - void writeNumberCoupleAsNumberArray(String name, N couple) throws IOException { + void writeNumberCoupleAsNumberArray(String name, N couple) + throws IOException { generator.writeArrayFieldStart(name); generator.writeObject(couple.getFirst()); generator.writeObject(couple.getSecond()); diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/config/serializers/StandardCVPipelineSettingsDeserializer.java b/chameleon-server/src/main/java/com/chameleonvision/_2/config/serializers/StandardCVPipelineSettingsDeserializer.java index 679545cba..012010010 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/config/serializers/StandardCVPipelineSettingsDeserializer.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/config/serializers/StandardCVPipelineSettingsDeserializer.java @@ -5,10 +5,10 @@ import com.chameleonvision._2.vision.pipeline.impl.StandardCVPipelineSettings; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationContext; - import java.io.IOException; -public class StandardCVPipelineSettingsDeserializer extends BaseDeserializer { +public class StandardCVPipelineSettingsDeserializer + extends BaseDeserializer { public StandardCVPipelineSettingsDeserializer() { this(null); } @@ -18,7 +18,8 @@ public class StandardCVPipelineSettingsDeserializer extends BaseDeserializer { +public class StandardCVPipelineSettingsSerializer + extends BaseSerializer { public StandardCVPipelineSettingsSerializer() { this(null); } @@ -16,7 +16,9 @@ public class StandardCVPipelineSettingsSerializer extends BaseSerializer getNetworkInterfaces() throws SocketException { List netInterfaces; @@ -98,6 +97,5 @@ public class LinuxNetworking extends SysNetworking { goodInterfaces.add(netInterface); } return goodInterfaces; - } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/network/NetmaskToCIDR.java b/chameleon-server/src/main/java/com/chameleonvision/_2/network/NetmaskToCIDR.java index d68a2074a..95a4d1053 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/network/NetmaskToCIDR.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/network/NetmaskToCIDR.java @@ -3,7 +3,8 @@ package com.chameleonvision._2.network; import java.net.InetAddress; public class NetmaskToCIDR { - //code belongs to https://stackoverflow.com/questions/19531411/calculate-cidr-from-a-given-netmask-java + // code belongs to + // https://stackoverflow.com/questions/19531411/calculate-cidr-from-a-given-netmask-java public static int convertNetmaskToCIDR(InetAddress netmask) { byte[] netmaskBytes = netmask.getAddress(); diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/network/NetworkInterface.java b/chameleon-server/src/main/java/com/chameleonvision/_2/network/NetworkInterface.java index 6b369e692..577b84e4f 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/network/NetworkInterface.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/network/NetworkInterface.java @@ -4,19 +4,19 @@ import java.net.InterfaceAddress; @SuppressWarnings("WeakerAccess") public class NetworkInterface { - public final String name; - public final String displayName; - public final String IPAddress; - public final String Netmask; - public final String Gateway; - public final String Broadcast; + public final String name; + public final String displayName; + public final String IPAddress; + public final String Netmask; + public final String Gateway; + public final String Broadcast; - public NetworkInterface(java.net.NetworkInterface inetface, InterfaceAddress ifaceAddress) { - name = inetface.getName(); - displayName = inetface.getDisplayName(); + public NetworkInterface(java.net.NetworkInterface inetface, InterfaceAddress ifaceAddress) { + name = inetface.getName(); + displayName = inetface.getDisplayName(); - var inetAddress = ifaceAddress.getAddress(); - IPAddress = inetAddress.getHostAddress(); + var inetAddress = ifaceAddress.getAddress(); + IPAddress = inetAddress.getHostAddress(); Netmask = getIPv4LocalNetMask(ifaceAddress); // TODO: (low) hack to "get" gateway, this is gross and bad, pls fix @@ -25,14 +25,14 @@ public class NetworkInterface { Gateway = String.join(".", splitIPAddr); splitIPAddr[3] = "255"; Broadcast = String.join(".", splitIPAddr); - } + } private static String getIPv4LocalNetMask(InterfaceAddress interfaceAddress) { - var netPrefix = interfaceAddress.getNetworkPrefixLength(); + var netPrefix = interfaceAddress.getNetworkPrefixLength(); try { // Since this is for IPv4, it's 32 bits, so set the sign value of // the int to "negative"... - int shiftby = (1<<31); + int shiftby = (1 << 31); // For the number of bits of the prefix -1 (we already set the sign bit) for (int i = netPrefix - 1; i > 0; i--) { // Shift the sign right... Java makes the sign bit sticky on a shift... @@ -41,11 +41,16 @@ public class NetworkInterface { } // Transform the resulting value in xxx.xxx.xxx.xxx format, like if /// it was a standard address... - // Return the address thus created... - return ((shiftby >> 24) & 255) + "." + ((shiftby >> 16) & 255) + "." + ((shiftby >> 8) & 255) + "." + (shiftby & 255); -// return InetAddress.getByName(maskString); - } - catch(Exception e) { + // Return the address thus created... + return ((shiftby >> 24) & 255) + + "." + + ((shiftby >> 16) & 255) + + "." + + ((shiftby >> 8) & 255) + + "." + + (shiftby & 255); + // return InetAddress.getByName(maskString); + } catch (Exception e) { e.printStackTrace(); } // Something went wrong here... diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/network/NetworkManager.java b/chameleon-server/src/main/java/com/chameleonvision/_2/network/NetworkManager.java index be5e28ea7..ea96d41ec 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/network/NetworkManager.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/network/NetworkManager.java @@ -2,14 +2,12 @@ package com.chameleonvision._2.network; import com.chameleonvision._2.config.ConfigManager; import com.chameleonvision.common.util.Platform; - import java.net.SocketException; import java.util.ArrayList; import java.util.List; public class NetworkManager { - private NetworkManager() { - } + private NetworkManager() {} private static SysNetworking networking; private static boolean isManaged = false; @@ -25,7 +23,7 @@ public class NetworkManager { if (platform.isLinux()) { networking = new LinuxNetworking(); } else if (platform.isWindows()) { -// networking = new WindowsNetworking(); + // networking = new WindowsNetworking(); System.out.println("Windows networking is not yet supported. Running unmanaged."); return; } @@ -71,10 +69,9 @@ public class NetworkManager { } private static byte[] GetTeamNumberIPBytes(int teamNumber) { - return new byte[]{(byte) (teamNumber / 100), (byte) (teamNumber % 100)}; + return new byte[] {(byte) (teamNumber / 100), (byte) (teamNumber % 100)}; } - private static boolean setDHCP() { if (!isManaged) { return true; diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/network/SysNetworking.java b/chameleon-server/src/main/java/com/chameleonvision/_2/network/SysNetworking.java index 707a9c384..68291f04d 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/network/SysNetworking.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/network/SysNetworking.java @@ -1,36 +1,38 @@ package com.chameleonvision._2.network; import com.chameleonvision.common.util.ShellExec; - import java.io.IOException; import java.net.SocketException; import java.util.List; public abstract class SysNetworking { - NetworkInterface networkInterface; - ShellExec shell = new ShellExec(true, true); + NetworkInterface networkInterface; + ShellExec shell = new ShellExec(true, true); - public String getHostname() { - try { - var retCode = shell.execute("hostname", null, true); - if (retCode == 0) { - while(!shell.isOutputCompleted()) {} - return shell.getOutput(); - } else { - return null; - } - } catch (IOException e) { - return null; - } - } + public String getHostname() { + try { + var retCode = shell.execute("hostname", null, true); + if (retCode == 0) { + while (!shell.isOutputCompleted()) {} + return shell.getOutput(); + } else { + return null; + } + } catch (IOException e) { + return null; + } + } - public void setNetworkInterface(NetworkInterface networkInterface) { - this.networkInterface = networkInterface; - } - public abstract boolean setDHCP(); - public abstract boolean setHostname(String hostname); - public abstract boolean setStatic(String ipAddress, String netmask, String gateway); - public abstract List getNetworkInterfaces() throws SocketException; + public void setNetworkInterface(NetworkInterface networkInterface) { + this.networkInterface = networkInterface; + } + public abstract boolean setDHCP(); + + public abstract boolean setHostname(String hostname); + + public abstract boolean setStatic(String ipAddress, String netmask, String gateway); + + public abstract List getNetworkInterfaces() throws SocketException; } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/network/WindowsNetworking.java b/chameleon-server/src/main/java/com/chameleonvision/_2/network/WindowsNetworking.java index 9e77d3b8b..7565585f6 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/network/WindowsNetworking.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/network/WindowsNetworking.java @@ -7,48 +7,51 @@ import java.util.List; public class WindowsNetworking extends SysNetworking { - @Override - public boolean setDHCP() { - return false; - } + @Override + public boolean setDHCP() { + return false; + } - @Override - public boolean setHostname(String newHostname) { - var currentHostname = getHostname(); + @Override + public boolean setHostname(String newHostname) { + var currentHostname = getHostname(); - if (getHostname() == null) { - return false; - } + if (getHostname() == null) { + return false; + } - String command = String.format("wmic computersystem where name=\"%s\" call rename name=\"%s\"", currentHostname, newHostname); + String command = + String.format( + "wmic computersystem where name=\"%s\" call rename name=\"%s\"", + currentHostname, newHostname); - try { - var process = Runtime.getRuntime().exec(command); - var returnCode = process.waitFor(); - return returnCode == 0; - } catch(Exception e) { - return false; - } - } + try { + var process = Runtime.getRuntime().exec(command); + var returnCode = process.waitFor(); + return returnCode == 0; + } catch (Exception e) { + return false; + } + } - @Override - public boolean setStatic(String ipAddress, String netmask, String gateway) { - return false; - } + @Override + public boolean setStatic(String ipAddress, String netmask, String gateway) { + return false; + } - @Override - public List getNetworkInterfaces() throws SocketException { - var netInterfaces = Collections.list(java.net.NetworkInterface.getNetworkInterfaces()); + @Override + public List getNetworkInterfaces() throws SocketException { + var netInterfaces = Collections.list(java.net.NetworkInterface.getNetworkInterfaces()); - List goodInterfaces = new ArrayList<>(); + List goodInterfaces = new ArrayList<>(); - for (var netInterface : netInterfaces) { - if (netInterface.getDisplayName().toLowerCase().contains("bluetooth")) continue; - if (netInterface.getDisplayName().toLowerCase().contains("virtual")) continue; - if (netInterface.getDisplayName().toLowerCase().contains("loopback")) continue; - if (!netInterface.isUp()) continue; - goodInterfaces.add(netInterface); - } - return goodInterfaces; - } + for (var netInterface : netInterfaces) { + if (netInterface.getDisplayName().toLowerCase().contains("bluetooth")) continue; + if (netInterface.getDisplayName().toLowerCase().contains("virtual")) continue; + if (netInterface.getDisplayName().toLowerCase().contains("loopback")) continue; + if (!netInterface.isUp()) continue; + goodInterfaces.add(netInterface); + } + return goodInterfaces; + } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/util/Helpers.java b/chameleon-server/src/main/java/com/chameleonvision/_2/util/Helpers.java index 2d48b5575..fa6a044da 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/util/Helpers.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/util/Helpers.java @@ -1,7 +1,6 @@ package com.chameleonvision._2.util; import edu.wpi.cscore.VideoMode; - import java.io.File; import java.io.FileWriter; import java.io.IOException; @@ -13,31 +12,32 @@ public class Helpers { // TODO: MOVE public static HashMap VideoModeToHashMap(VideoMode videoMode) { - return new HashMap() {{ - put("width", videoMode.width); - put("height", videoMode.height); - put("fps", videoMode.fps); - put("pixelFormat", videoMode.pixelFormat.toString()); - }}; + return new HashMap() { + { + put("width", videoMode.width); + put("height", videoMode.height); + put("fps", videoMode.fps); + put("pixelFormat", videoMode.pixelFormat.toString()); + } + }; } - // TODO: MOVE private static final String kServicePath = "/etc/systemd/system/chameleonVision.service"; - private static final String kServiceString = "[Unit]\n" + - "Description=chameleon vision\n" + - "\n" + - "[Service]\n" + - "ExecStart=/usr/bin/java -jar %s \n" + - "StandardOutput=file:/var/log/chameleon.out.txt\n" + - "StandardError=file:/var/log/chameleon.err.txt\n" + - "Type=simple\n" + - "WorkingDirectory=/usr/local/bin\n" + - "\n" + - "[Install]\n" + - "WantedBy=multi-user.target\n" + - "\n"; - + private static final String kServiceString = + "[Unit]\n" + + "Description=chameleon vision\n" + + "\n" + + "[Service]\n" + + "ExecStart=/usr/bin/java -jar %s \n" + + "StandardOutput=file:/var/log/chameleon.out.txt\n" + + "StandardError=file:/var/log/chameleon.err.txt\n" + + "Type=simple\n" + + "WorkingDirectory=/usr/local/bin\n" + + "\n" + + "[Install]\n" + + "WantedBy=multi-user.target\n" + + "\n"; public static void setService(Path filePath) throws IOException, InterruptedException { String newService = String.format(kServiceString, filePath.toString()); diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/util/ProgramDirectoryUtilities.java b/chameleon-server/src/main/java/com/chameleonvision/_2/util/ProgramDirectoryUtilities.java index 90bb10545..3b231563e 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/util/ProgramDirectoryUtilities.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/util/ProgramDirectoryUtilities.java @@ -5,10 +5,12 @@ import java.net.URISyntaxException; public class ProgramDirectoryUtilities { private static String getJarName() { - return new File(ProgramDirectoryUtilities.class.getProtectionDomain() - .getCodeSource() - .getLocation() - .getPath()) + return new File( + ProgramDirectoryUtilities.class + .getProtectionDomain() + .getCodeSource() + .getLocation() + .getPath()) .getName(); } @@ -27,11 +29,18 @@ public class ProgramDirectoryUtilities { private static String getCurrentJARDirectory() { try { - return new File(ProgramDirectoryUtilities.class.getProtectionDomain().getCodeSource().getLocation().toURI().getPath()).getParent(); + return new File( + ProgramDirectoryUtilities.class + .getProtectionDomain() + .getCodeSource() + .getLocation() + .toURI() + .getPath()) + .getParent(); } catch (URISyntaxException exception) { exception.printStackTrace(); } return null; } -} \ No newline at end of file +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/VisionManager.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/VisionManager.java index 4c11e6dc3..7e0d1628b 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/VisionManager.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/VisionManager.java @@ -8,17 +8,17 @@ import com.chameleonvision._2.vision.pipeline.CVPipelineSettings; import com.chameleonvision.common.util.Platform; import edu.wpi.cscore.UsbCamera; import edu.wpi.cscore.UsbCameraInfo; -import org.opencv.videoio.VideoCapture; - import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.opencv.videoio.VideoCapture; @SuppressWarnings("rawtypes") public class VisionManager { private VisionManager() {} - private static final LinkedHashMap usbCameraInfosByCameraName = new LinkedHashMap<>(); + private static final LinkedHashMap usbCameraInfosByCameraName = + new LinkedHashMap<>(); private static final LinkedList loadedCameraConfigs = new LinkedList<>(); private static final LinkedList visionProcesses = new LinkedList<>(); @@ -43,7 +43,8 @@ public class VisionManager { VideoCapture cap = new VideoCapture(info.dev); if (cap.isOpened()) { cap.release(); - // Filter non-ascii characters because ext4 doesn't play nice with unicode in directory names + // Filter non-ascii characters because ext4 doesn't play nice with unicode in directory + // names String name = info.name.replaceAll("[^\\x00-\\x7F]", ""); while (usbCameraInfosByCameraName.containsKey(name)) { suffix++; @@ -61,17 +62,22 @@ public class VisionManager { // load the config List preliminaryConfigs = new ArrayList<>(); - usbCameraInfosByCameraName.forEach((suffixedName, cameraInfo) -> { - String truePath; + usbCameraInfosByCameraName.forEach( + (suffixedName, cameraInfo) -> { + String truePath; - if (Platform.CurrentPlatform.isWindows()) { - truePath = cameraInfo.path; - } else { - truePath = Arrays.stream(cameraInfo.otherPaths).filter(x -> x.contains("/dev/v4l/by-path")).findFirst().orElse(cameraInfo.path); - } + if (Platform.CurrentPlatform.isWindows()) { + truePath = cameraInfo.path; + } else { + truePath = + Arrays.stream(cameraInfo.otherPaths) + .filter(x -> x.contains("/dev/v4l/by-path")) + .findFirst() + .orElse(cameraInfo.path); + } - preliminaryConfigs.add(new CameraJsonConfig(truePath, suffixedName)); - }); + preliminaryConfigs.add(new CameraJsonConfig(truePath, suffixedName)); + }); loadedCameraConfigs.addAll(ConfigManager.initializeCameras(preliminaryConfigs)); System.out.printf("[VisionManager] Loaded %s cameras!\n", loadedCameraConfigs.size()); @@ -92,7 +98,9 @@ public class VisionManager { currentUIVisionProcess = getVisionProcessByIndex(0); ConfigManager.settings.currentCamera = visionProcesses.get(0).name; - System.out.printf("[VisionManager] Loaded %s vision processes! Current process: %s\n", visionProcesses.size(), visionProcesses.get(0).name); + System.out.printf( + "[VisionManager] Loaded %s vision processes! Current process: %s\n", + visionProcesses.size(), visionProcesses.get(0).name); return true; } @@ -110,7 +118,12 @@ public class VisionManager { public static CameraConfig getCameraConfig(VisionProcess process) { String cameraName = process.getCamera().getProperties().name; - return Objects.requireNonNull(loadedCameraConfigs.stream().filter(x -> x.cameraConfig.name.equals(cameraName)).findFirst().orElse(null)).fileConfig; + return Objects.requireNonNull( + loadedCameraConfigs.stream() + .filter(x -> x.cameraConfig.name.equals(cameraName)) + .findFirst() + .orElse(null)) + .fileConfig; } public static void setCurrentProcessByIndex(int processIndex) { @@ -127,32 +140,42 @@ public class VisionManager { return null; } - VisionProcessManageable vpm = visionProcesses.stream().filter(manageable -> manageable.index == processIndex).findFirst().orElse(null); + VisionProcessManageable vpm = + visionProcesses.stream() + .filter(manageable -> manageable.index == processIndex) + .findFirst() + .orElse(null); return vpm != null ? vpm.visionProcess : null; } public static List getAllCameraNicknames() { - return visionProcesses.stream().map(vpm -> vpm.visionProcess.getCamera() - .getProperties().getNickname()).collect(Collectors.toList()); + return visionProcesses.stream() + .map(vpm -> vpm.visionProcess.getCamera().getProperties().getNickname()) + .collect(Collectors.toList()); } public static List getCurrentCameraPipelineNicknames() { - return currentUIVisionProcess.pipelineManager.pipelines.stream().map(cvPipeline -> cvPipeline.settings.nickname).collect(Collectors.toList()); + return currentUIVisionProcess.pipelineManager.pipelines.stream() + .map(cvPipeline -> cvPipeline.settings.nickname) + .collect(Collectors.toList()); } - public static void saveAllCameras() { - visionProcesses.forEach((vpm) -> { - VisionProcess process = vpm.visionProcess; - String cameraName = process.getCamera().getProperties().name; - Stream pipelineStream = process.pipelineManager.pipelines.stream(); - List pipelines = process.pipelineManager.pipelines.stream().map(cvPipeline -> cvPipeline.settings).collect(Collectors.toList()); - CVPipelineSettings driverMode = process.getDriverModeSettings(); - CameraJsonConfig config = CameraJsonConfig.fromVisionProcess(process); - ConfigManager.saveCameraPipelines(cameraName, pipelines); - ConfigManager.saveCameraDriverMode(cameraName, driverMode); - ConfigManager.saveCameraConfig(cameraName, config); - }); + visionProcesses.forEach( + (vpm) -> { + VisionProcess process = vpm.visionProcess; + String cameraName = process.getCamera().getProperties().name; + Stream pipelineStream = process.pipelineManager.pipelines.stream(); + List pipelines = + process.pipelineManager.pipelines.stream() + .map(cvPipeline -> cvPipeline.settings) + .collect(Collectors.toList()); + CVPipelineSettings driverMode = process.getDriverModeSettings(); + CameraJsonConfig config = CameraJsonConfig.fromVisionProcess(process); + ConfigManager.saveCameraPipelines(cameraName, pipelines); + ConfigManager.saveCameraDriverMode(cameraName, driverMode); + ConfigManager.saveCameraConfig(cameraName, config); + }); } private static String getCurrentCameraName() { @@ -173,7 +196,9 @@ public class VisionManager { } private static List getCameraResolutionList(USBCameraCapture capture) { - return capture.getProperties().getVideoModes().stream().map(Helpers::VideoModeToHashMap).collect(Collectors.toList()); + return capture.getProperties().getVideoModes().stream() + .map(Helpers::VideoModeToHashMap) + .collect(Collectors.toList()); } public static List getCurrentCameraResolutionList() { @@ -181,7 +206,11 @@ public class VisionManager { } public static int getCurrentUIVisionProcessIndex() { - VisionProcessManageable vpm = visionProcesses.stream().filter(v -> v.visionProcess == currentUIVisionProcess).findFirst().orElse(null); + VisionProcessManageable vpm = + visionProcesses.stream() + .filter(v -> v.visionProcess == currentUIVisionProcess) + .findFirst() + .orElse(null); return vpm != null ? vpm.index : -1; } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/VisionProcess.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/VisionProcess.java index 26fcba551..22ec5c603 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/VisionProcess.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/VisionProcess.java @@ -23,14 +23,12 @@ import edu.wpi.cscore.VideoMode; import edu.wpi.first.networktables.*; import edu.wpi.first.wpilibj.geometry.Pose2d; import edu.wpi.first.wpiutil.CircularBuffer; -import org.apache.commons.lang3.tuple.Pair; -import org.opencv.core.Mat; - import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.stream.Collectors; - +import org.apache.commons.lang3.tuple.Pair; +import org.opencv.core.Mat; @SuppressWarnings("rawtypes") public class VisionProcess { @@ -75,31 +73,38 @@ public class VisionProcess { pipelineManager = new PipelineManager(this, config.pipelines); // Thread to put frames on the dashboard - this.cameraStreamer = new CameraStreamer(cameraCapture, config.cameraConfig.name, pipelineManager.getCurrentPipeline().settings.streamDivisor); + this.cameraStreamer = + new CameraStreamer( + cameraCapture, + config.cameraConfig.name, + pipelineManager.getCurrentPipeline().settings.streamDivisor); // Thread to process vision data this.visionRunnable = new VisionProcessRunnable(); // network table - defaultTable = NetworkTableInstance.getDefault().getTable("/chameleon-vision/" + cameraCapture.getProperties().getNickname()); + defaultTable = + NetworkTableInstance.getDefault() + .getTable("/chameleon-vision/" + cameraCapture.getProperties().getNickname()); } public void start() { - System.out.printf("[%s Process] Creating network table...\n", getCamera().getProperties().getNickname()); + System.out.printf( + "[%s Process] Creating network table...\n", getCamera().getProperties().getNickname()); initNT(defaultTable); - System.out.printf("[%s Process] Starting vision thread...\n", getCamera().getProperties().getNickname()); + System.out.printf( + "[%s Process] Starting vision thread...\n", getCamera().getProperties().getNickname()); var visionThread = new Thread(visionRunnable); visionThread.setName(getCamera().getProperties().name + " - Vision Thread"); visionThread.start(); } /** - * Removes the old value change listeners - * calls {@link #initNT} - * - * @param newTable passed to {@link #initNT} - */ + * Removes the old value change listeners calls {@link #initNT} + * + * @param newTable passed to {@link #initNT} + */ public void resetNT(NetworkTable newTable) { ntDriverModeEntry.removeListener(ntDriveModeListenerID); ntPipelineEntry.removeListener(ntPipelineListenerID); @@ -128,8 +133,10 @@ public class VisionProcess { ntBoundingHeightEntry = camTable.getEntry("targetBoundingHeight"); ntBoundingWidthEntry = camTable.getEntry("targetBoundingWidth"); ntTargetRotation = camTable.getEntry("targetRotation"); - ntDriveModeListenerID = ntDriverModeEntry.addListener(this::setDriverMode, EntryListenerFlags.kUpdate); - ntPipelineListenerID = ntPipelineEntry.addListener(this::setPipeline, EntryListenerFlags.kUpdate); + ntDriveModeListenerID = + ntDriverModeEntry.addListener(this::setDriverMode, EntryListenerFlags.kUpdate); + ntPipelineListenerID = + ntPipelineEntry.addListener(this::setPipeline, EntryListenerFlags.kUpdate); ntDriverModeEntry.setBoolean(false); ntPipelineEntry.setNumber(pipelineManager.getCurrentPipelineIndex()); pipelineManager.ntIndexEntry = ntPipelineEntry; @@ -141,15 +148,16 @@ public class VisionProcess { public void setDriverMode(boolean driverMode) { pipelineManager.setDriverMode(driverMode); - ScriptManager.queueEvent(driverMode ? ScriptEventType.kEnterDriverMode : ScriptEventType.kExitDriverMode); + ScriptManager.queueEvent( + driverMode ? ScriptEventType.kEnterDriverMode : ScriptEventType.kExitDriverMode); SocketHandler.sendFullSettings(); } /** - * Method called by the nt entry listener to update the next pipeline. - * - * @param notification the notification - */ + * Method called by the nt entry listener to update the next pipeline. + * + * @param notification the notification + */ private void setPipeline(EntryNotification notification) { var wantedPipelineIndex = (int) notification.value.getDouble(); if (pipelineManager.pipelines.size() - 1 < wantedPipelineIndex) { @@ -173,7 +181,6 @@ public class VisionProcess { if (currentMillis - lastUIUpdateMs > 1000 / 30) { lastUIUpdateMs = currentMillis; - if (cameraCapture.getProperties().name.equals(ConfigManager.settings.currentCamera)) { HashMap WebSend = new HashMap<>(); HashMap point = new HashMap<>(); @@ -181,13 +188,14 @@ public class VisionProcess { ArrayList webTargets = new ArrayList<>(); List center = new ArrayList<>(); - if (data.hasTarget) { if (data instanceof StandardCVPipeline.StandardCVPipelineResult) { - StandardCVPipeline.StandardCVPipelineResult result = (StandardCVPipeline.StandardCVPipelineResult) data; + StandardCVPipeline.StandardCVPipelineResult result = + (StandardCVPipeline.StandardCVPipelineResult) data; StandardCVPipeline.TrackedTarget bestTarget = result.targets.get(0); try { - if (((StandardCVPipelineSettings) pipelineManager.getCurrentPipeline().settings).multiple) { + if (((StandardCVPipelineSettings) pipelineManager.getCurrentPipeline().settings) + .multiple) { for (var target : result.targets) { pointMap = new HashMap<>(); pointMap.put("pitch", target.pitch); @@ -206,7 +214,7 @@ public class VisionProcess { center.add(bestTarget.minAreaRect.center.x); center.add(bestTarget.minAreaRect.center.y); } catch (ClassCastException ignored) { - + } } else { pointMap.put("pitch", null); @@ -236,7 +244,8 @@ public class VisionProcess { if (data instanceof StandardCVPipeline.StandardCVPipelineResult) { //noinspection unchecked - List targets = (List) data.targets; + List targets = + (List) data.targets; StandardCVPipeline.TrackedTarget bestTarget = targets.get(0); ntLatencyEntry.setDouble(MathUtils.roundTo(data.processTime * 1e-6, 3)); ntPitchEntry.setDouble(bestTarget.pitch); @@ -249,12 +258,30 @@ public class VisionProcess { ntTargetRotation.setDouble(bestTarget.minAreaRect.angle); try { Pose2d targetPose = targets.get(0).cameraRelativePose; - double[] targetArray = {targetPose.getTranslation().getX(), targetPose.getTranslation().getY(), targetPose.getRotation().getDegrees()}; + double[] targetArray = { + targetPose.getTranslation().getX(), + targetPose.getTranslation().getY(), + targetPose.getRotation().getDegrees() + }; ntPoseEntry.setDoubleArray(targetArray); -// ntPoseEntry.setString(objectMapper.writeValueAsString(targets.get(0).cameraRelativePose)); - ntAuxListEntry.setString(objectMapper.writeValueAsString(targets.stream() - .map(it -> List.of(it.pitch, it.yaw, it.area, it.boundingRect.width, it.boundingRect.height, it.minAreaRect.size.width, it.minAreaRect.size.height, it.minAreaRect.angle, it.cameraRelativePose)) - .collect(Collectors.toList()))); + // + // ntPoseEntry.setString(objectMapper.writeValueAsString(targets.get(0).cameraRelativePose)); + ntAuxListEntry.setString( + objectMapper.writeValueAsString( + targets.stream() + .map( + it -> + List.of( + it.pitch, + it.yaw, + it.area, + it.boundingRect.width, + it.boundingRect.height, + it.minAreaRect.size.width, + it.minAreaRect.size.height, + it.minAreaRect.angle, + it.cameraRelativePose)) + .collect(Collectors.toList()))); } catch (JsonProcessingException e) { e.printStackTrace(); } @@ -311,9 +338,7 @@ public class VisionProcess { return false; } - /** - * VisionProcessRunnable will process images as quickly as possible - */ + /** VisionProcessRunnable will process images as quickly as possible */ private class VisionProcessRunnable implements Runnable { volatile Double fps = 0.0; @@ -324,21 +349,23 @@ public class VisionProcess { var lastUpdateTimeNanos = System.nanoTime(); var lastStreamTimeMs = System.currentTimeMillis(); - System.out.printf("[%s Process] Vision Process Thread -- first run!\n", getCamera().getProperties().getNickname()); + System.out.printf( + "[%s Process] Vision Process Thread -- first run!\n", + getCamera().getProperties().getNickname()); while (!Thread.interrupted()) { // blocking call, will block until camera has a new frame. Pair camData = cameraCapture.getFrame(); - Mat camFrame = camData.getLeft(); if (camFrame.cols() > 0 && camFrame.rows() > 0) { CVPipelineResult result = null; try { result = pipelineManager.getCurrentPipeline().runPipeline(camFrame); } catch (Exception e) { - System.err.println("Exception in vision process " + getCamera().getProperties().getNickname() + "!"); + System.err.println( + "Exception in vision process " + getCamera().getProperties().getNickname() + "!"); e.printStackTrace(); } @@ -355,18 +382,22 @@ public class VisionProcess { try { var currentTime = System.currentTimeMillis(); if ((currentTime - lastStreamTimeMs) / 1000d > 1.0 / 30.0) { - if(lastPipelineResult != null) { + if (lastPipelineResult != null) { cameraStreamer.runStream(lastPipelineResult.outputMat); lastStreamTimeMs = currentTime; lastPipelineResult.outputMat.release(); } else { - System.err.printf("[%s Process] Last pipeline result was null!\n", getCamera().getProperties().getNickname()); + System.err.printf( + "[%s Process] Last pipeline result was null!\n", + getCamera().getProperties().getNickname()); } } } catch (Exception e) { -// Debug.printInfo("Vision running faster than stream."); - System.err.printf("[%s Process] Exception in vision thread!\n", getCamera().getProperties().getNickname()); + // Debug.printInfo("Vision running faster than stream."); + System.err.printf( + "[%s Process] Exception in vision thread!\n", + getCamera().getProperties().getNickname()); e.printStackTrace(); } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/CameraCapture.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/CameraCapture.java index 8d13b8a91..0fdd42955 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/CameraCapture.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/CameraCapture.java @@ -4,7 +4,6 @@ import com.chameleonvision._2.config.CameraCalibrationConfig; import com.chameleonvision._2.vision.image.CaptureProperties; import com.chameleonvision._2.vision.image.ImageCapture; import edu.wpi.cscore.VideoMode; - import java.util.List; public interface CameraCapture extends ImageCapture { @@ -13,36 +12,41 @@ public interface CameraCapture extends ImageCapture { VideoMode getCurrentVideoMode(); /** - * Set the exposure of the camera - * @param exposure the new exposure to set the camera to - */ + * Set the exposure of the camera + * + * @param exposure the new exposure to set the camera to + */ void setExposure(int exposure); /** - * Set the brightness of the camera - * @param brightness the new brightness to set the camera to - */ + * Set the brightness of the camera + * + * @param brightness the new brightness to set the camera to + */ void setBrightness(int brightness); /** - * Set the video mode (fps and resolution) of the camera - * @param mode the desired mode - */ + * Set the video mode (fps and resolution) of the camera + * + * @param mode the desired mode + */ void setVideoMode(VideoMode mode); /** - * Set the video mode (fps and resolution) of the camera - * @param index the index of the desired mode - */ + * Set the video mode (fps and resolution) of the camera + * + * @param index the index of the desired mode + */ void setVideoMode(int index); /** - * Set the gain of the camera - * NOTE - Not all cameras support this. - * @param gain the new gain to set the camera to - */ + * Set the gain of the camera NOTE - Not all cameras support this. + * + * @param gain the new gain to set the camera to + */ void setGain(int gain); CameraCalibrationConfig getCurrentCalibrationData(); + List getAllCalibrationData(); } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/CameraStreamer.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/CameraStreamer.java index f4f4bb31d..d4f839d37 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/CameraStreamer.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/CameraStreamer.java @@ -24,14 +24,17 @@ public class CameraStreamer { this.divisor = div; this.cameraCapture = cameraCapture; this.name = name; - this.cvSource = CameraServer.getInstance().putVideo(name, - cameraCapture.getProperties().getStaticProperties().imageWidth / divisor.value, - cameraCapture.getProperties().getStaticProperties().imageHeight / divisor.value); + this.cvSource = + CameraServer.getInstance() + .putVideo( + name, + cameraCapture.getProperties().getStaticProperties().imageWidth / divisor.value, + cameraCapture.getProperties().getStaticProperties().imageHeight / divisor.value); //noinspection IntegerDivisionInFloatingPointContext - this.size = new Size( - cameraCapture.getProperties().getStaticProperties().imageWidth / divisor.value, - cameraCapture.getProperties().getStaticProperties().imageHeight / divisor.value - ); + this.size = + new Size( + cameraCapture.getProperties().getStaticProperties().imageWidth / divisor.value, + cameraCapture.getProperties().getStaticProperties().imageHeight / divisor.value); setDivisor(divisor, false); } @@ -44,15 +47,16 @@ public class CameraStreamer { synchronized (streamBufferLock) { this.streamBuffer = new Mat(newWidth, newHeight, CvType.CV_8UC3); VideoMode oldVideoMode = cvSource.getVideoMode(); - cvSource.setVideoMode(new VideoMode(oldVideoMode.pixelFormat, - cameraCapture.getProperties().getStaticProperties().imageWidth / divisor.value, - cameraCapture.getProperties().getStaticProperties().imageHeight / divisor.value, - oldVideoMode.fps)); + cvSource.setVideoMode( + new VideoMode( + oldVideoMode.pixelFormat, + cameraCapture.getProperties().getStaticProperties().imageWidth / divisor.value, + cameraCapture.getProperties().getStaticProperties().imageHeight / divisor.value, + oldVideoMode.fps)); } if (updateUI) { SocketHandler.sendFullSettings(); } - } public StreamDivisor getDivisor() { @@ -80,21 +84,24 @@ public class CameraStreamer { } if (divisor.value != 1) { -// var camVal = cameraProcess.getProperties().staticProperties; -// var newWidth = camVal.imageWidth / divisor.value; -// var newHeight = camVal.imageHeight / divisor.value; -// Size newSize = new Size(newWidth, newHeight); - Imgproc.resize(streamBuffer, streamBuffer, this.size); + // var camVal = cameraProcess.getProperties().staticProperties; + // var newWidth = camVal.imageWidth / divisor.value; + // var newHeight = camVal.imageHeight / divisor.value; + // Size newSize = new Size(newWidth, newHeight); + Imgproc.resize(streamBuffer, streamBuffer, this.size); } var sourceVideoMode = cvSource.getVideoMode(); var imageSize = streamBuffer.size(); - if(sourceVideoMode.width != (int) imageSize.width || sourceVideoMode.height != (int) imageSize.height) { + if (sourceVideoMode.width != (int) imageSize.width + || sourceVideoMode.height != (int) imageSize.height) { synchronized (streamBufferLock) { - cvSource.setVideoMode(new VideoMode(sourceVideoMode.pixelFormat, - (int)imageSize.width, - (int) imageSize.height, - sourceVideoMode.fps)); + cvSource.setVideoMode( + new VideoMode( + sourceVideoMode.pixelFormat, + (int) imageSize.width, + (int) imageSize.height, + sourceVideoMode.fps)); } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/CaptureStaticProperties.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/CaptureStaticProperties.java index 20149c207..81f854b9d 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/CaptureStaticProperties.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/CaptureStaticProperties.java @@ -31,9 +31,11 @@ public class CaptureStaticProperties { int horizontalRatio = aspectFraction.getNumerator(); int verticalRatio = aspectFraction.getDenominator(); double diagonalAspect = FastMath.hypot(horizontalRatio, verticalRatio); - double horizontalView = FastMath.atan(FastMath.tan(diagonalView / 2) * (horizontalRatio / diagonalAspect)) * 2; - double verticalView = FastMath.atan(FastMath.tan(diagonalView / 2) * (verticalRatio / diagonalAspect)) * 2; - horizontalFocalLength = this.imageWidth / (2 * FastMath.tan(horizontalView /2)); - verticalFocalLength = this.imageHeight / (2 * FastMath.tan(verticalView /2)); + double horizontalView = + FastMath.atan(FastMath.tan(diagonalView / 2) * (horizontalRatio / diagonalAspect)) * 2; + double verticalView = + FastMath.atan(FastMath.tan(diagonalView / 2) * (verticalRatio / diagonalAspect)) * 2; + horizontalFocalLength = this.imageWidth / (2 * FastMath.tan(horizontalView / 2)); + verticalFocalLength = this.imageHeight / (2 * FastMath.tan(verticalView / 2)); } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/USBCameraCapture.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/USBCameraCapture.java index 937750219..844c542ef 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/USBCameraCapture.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/USBCameraCapture.java @@ -8,13 +8,12 @@ import edu.wpi.cscore.UsbCamera; import edu.wpi.cscore.VideoException; import edu.wpi.cscore.VideoMode; import edu.wpi.first.cameraserver.CameraServer; -import org.apache.commons.lang3.tuple.Pair; -import org.opencv.core.Mat; -import org.opencv.core.Size; - import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import org.apache.commons.lang3.tuple.Pair; +import org.opencv.core.Mat; +import org.opencv.core.Size; public class USBCameraCapture implements CameraCapture { private final UsbCamera baseCamera; @@ -25,22 +24,26 @@ public class USBCameraCapture implements CameraCapture { public USBCameraCapture(FullCameraConfiguration fullCameraConfiguration) { var config = fullCameraConfiguration.cameraConfig; - this.calibrationList = new ArrayList<>(); //fullCameraConfiguration.calibration; + this.calibrationList = new ArrayList<>(); // fullCameraConfiguration.calibration; calibrationList.addAll(fullCameraConfiguration.calibration); baseCamera = new UsbCamera(config.name, config.path); cvSink = CameraServer.getInstance().getVideo(baseCamera); try { properties = new USBCaptureProperties(baseCamera, config); - } catch(VideoException e) { - System.err.println("Camera cannot be found on the saved USB port!" + - " Ensure that the camera has not been plugged into a different USB port, and if so, correct it."); + } catch (VideoException e) { + System.err.println( + "Camera cannot be found on the saved USB port!" + + " Ensure that the camera has not been plugged into a different USB port, and if so, correct it."); e.printStackTrace(); } var videoModes = properties.getVideoModes(); - if(videoModes.size() < 1) { - throw new VideoException("0 video modes are valid! Full list provided by camera: \n\n" - + Arrays.stream(baseCamera.enumerateVideoModes()).map(Helpers::VideoModeToHashMap).toString() ); + if (videoModes.size() < 1) { + throw new VideoException( + "0 video modes are valid! Full list provided by camera: \n\n" + + Arrays.stream(baseCamera.enumerateVideoModes()) + .map(Helpers::VideoModeToHashMap) + .toString()); } int videoMode = properties.videoModes.size() - 1 <= config.videomode ? config.videomode : 0; @@ -48,8 +51,8 @@ public class USBCameraCapture implements CameraCapture { } public CameraCalibrationConfig getCalibration(Size size) { - for(var calibration: calibrationList) { - if(calibration.resolution.equals(size)) return calibration; + for (var calibration : calibrationList) { + if (calibration.resolution.equals(size)) return calibration; } return null; } @@ -59,7 +62,10 @@ public class USBCameraCapture implements CameraCapture { } public void addCalibrationData(CameraCalibrationConfig newConfig) { - calibrationList.removeIf(c -> newConfig.resolution.height == c.resolution.height && newConfig.resolution.width == c.resolution.width); + calibrationList.removeIf( + c -> + newConfig.resolution.height == c.resolution.height + && newConfig.resolution.width == c.resolution.width); calibrationList.add(newConfig); } @@ -79,7 +85,8 @@ public class USBCameraCapture implements CameraCapture { // TODO: Why multiply by 1000 here? Mat tempMat = new Mat(); deltaTime = cvSink.grabFrame(tempMat) * 1000L; -// tempMat = Imgcodecs.imread("C:\\Users\\imadu\\Documents\\GitHub\\chameleon-vision\\chameleon-server\\testimages\\2020\\image.png"); + // tempMat = + // Imgcodecs.imread("C:\\Users\\imadu\\Documents\\GitHub\\chameleon-vision\\chameleon-server\\testimages\\2020\\image.png"); tempMat.copyTo(imageBuffer); tempMat.release(); return Pair.of(imageBuffer, deltaTime); @@ -113,7 +120,7 @@ public class USBCameraCapture implements CameraCapture { } } - public void setVideoMode(int index){ + public void setVideoMode(int index) { VideoMode mode = properties.getVideoModes().get(index); setVideoMode(mode); } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/USBCaptureProperties.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/USBCaptureProperties.java index 365870cbc..a53975d22 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/USBCaptureProperties.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/camera/USBCaptureProperties.java @@ -6,7 +6,6 @@ import com.chameleonvision.common.util.Platform; import edu.wpi.cscore.UsbCamera; import edu.wpi.cscore.VideoMode; import edu.wpi.first.wpilibj.geometry.Rotation2d; - import java.util.Arrays; import java.util.List; import java.util.function.Predicate; @@ -25,11 +24,16 @@ public class USBCaptureProperties extends CaptureProperties { private static final int PS3EYE_VID = 0x1415; private static final int PS3EYE_PID = 0x2000; - private static final List ALLOWED_PIXEL_FORMATS = Arrays.asList(VideoMode.PixelFormat.kYUYV, VideoMode.PixelFormat.kMJPEG, VideoMode.PixelFormat.kBGR); + private static final List ALLOWED_PIXEL_FORMATS = + Arrays.asList( + VideoMode.PixelFormat.kYUYV, VideoMode.PixelFormat.kMJPEG, VideoMode.PixelFormat.kBGR); - private static final Predicate kMinFPSPredicate = (videoMode -> videoMode.fps >= MINIMUM_FPS); - private static final Predicate kMinSizePredicate = (videoMode -> videoMode.width >= MINIMUM_WIDTH && videoMode.height >= MINIMUM_HEIGHT); - private static final Predicate kPixelFormatPredicate = (videoMode -> ALLOWED_PIXEL_FORMATS.contains(videoMode.pixelFormat)); + private static final Predicate kMinFPSPredicate = + (videoMode -> videoMode.fps >= MINIMUM_FPS); + private static final Predicate kMinSizePredicate = + (videoMode -> videoMode.width >= MINIMUM_WIDTH && videoMode.height >= MINIMUM_HEIGHT); + private static final Predicate kPixelFormatPredicate = + (videoMode -> ALLOWED_PIXEL_FORMATS.contains(videoMode.pixelFormat)); public final String name; public final String path; @@ -89,7 +93,8 @@ public class USBCaptureProperties extends CaptureProperties { } private List filterVideoModes(VideoMode[] videoModes) { - Predicate fullPredicate = kMinFPSPredicate.and(kMinSizePredicate).and(kPixelFormatPredicate); + Predicate fullPredicate = + kMinFPSPredicate.and(kMinSizePredicate).and(kPixelFormatPredicate); Stream validModes = Arrays.stream(videoModes).filter(fullPredicate); return validModes.collect(Collectors.toList()); } @@ -102,16 +107,15 @@ public class USBCaptureProperties extends CaptureProperties { return videoModes; } - public VideoMode getVideoMode(int index){ + public VideoMode getVideoMode(int index) { return videoModes.get(index); } - public VideoMode getCurrentVideoMode() { return staticProperties.mode; } - - public int getCurrentVideoModeIndex(){ - return getVideoModes().indexOf(getCurrentVideoMode()); + public VideoMode getCurrentVideoMode() { + return staticProperties.mode; } - - + public int getCurrentVideoModeIndex() { + return getVideoModes().indexOf(getCurrentVideoMode()); + } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/CalibrationMode.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/CalibrationMode.java index 71d62d17e..f3a9d221f 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/CalibrationMode.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/CalibrationMode.java @@ -1,5 +1,7 @@ package com.chameleonvision._2.vision.enums; public enum CalibrationMode { - None,Single,Dual + None, + Single, + Dual } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/ImageRotationMode.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/ImageRotationMode.java index 7328dc02d..ab95ef7e3 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/ImageRotationMode.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/ImageRotationMode.java @@ -12,5 +12,7 @@ public enum ImageRotationMode { this.value = value; } - public boolean isRotated(){return this.value==DEG_90.value||this.value==DEG_270.value;} + public boolean isRotated() { + return this.value == DEG_90.value || this.value == DEG_270.value; + } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/SortMode.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/SortMode.java index db7acefbe..f3afb4ed9 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/SortMode.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/SortMode.java @@ -1,5 +1,11 @@ package com.chameleonvision._2.vision.enums; public enum SortMode { - Largest,Smallest,Highest,Lowest,Rightmost,Leftmost,Centermost + Largest, + Smallest, + Highest, + Lowest, + Rightmost, + Leftmost, + Centermost } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/TargetIntersection.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/TargetIntersection.java index 8e754faf9..52317e181 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/TargetIntersection.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/TargetIntersection.java @@ -1,5 +1,9 @@ package com.chameleonvision._2.vision.enums; public enum TargetIntersection { - None,Up,Down,Left,Right + None, + Up, + Down, + Left, + Right } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/TargetOrientation.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/TargetOrientation.java index 8276ecb1d..f37d67d08 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/TargetOrientation.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/TargetOrientation.java @@ -1,5 +1,6 @@ package com.chameleonvision._2.vision.enums; public enum TargetOrientation { - Portrait, Landscape + Portrait, + Landscape } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/TargetRegion.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/TargetRegion.java index 743046b4b..b15d176d3 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/TargetRegion.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/enums/TargetRegion.java @@ -1,5 +1,9 @@ package com.chameleonvision._2.vision.enums; public enum TargetRegion { - Center, Top, Bottom, Left, Right + Center, + Top, + Bottom, + Left, + Right } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/image/CaptureProperties.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/image/CaptureProperties.java index 816f927da..457de409b 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/image/CaptureProperties.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/image/CaptureProperties.java @@ -9,13 +9,16 @@ public class CaptureProperties { protected CaptureStaticProperties staticProperties; private Rotation2d tilt = new Rotation2d(); - protected CaptureProperties() { - } + protected CaptureProperties() {} public CaptureProperties(VideoMode videoMode, double fov) { staticProperties = new CaptureStaticProperties(videoMode, fov); } - public void setStaticProperties(CaptureStaticProperties staticProperties) {this.staticProperties = staticProperties;} + + public void setStaticProperties(CaptureStaticProperties staticProperties) { + this.staticProperties = staticProperties; + } + public CaptureStaticProperties getStaticProperties() { return staticProperties; } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/image/ImageCapture.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/image/ImageCapture.java index cd8e3a4d2..1b5eafe0d 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/image/ImageCapture.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/image/ImageCapture.java @@ -5,8 +5,9 @@ import org.opencv.core.Mat; public interface ImageCapture { /** - * Get the next camera frame - * @return a Pair of the captured image and the Linux epoch of when the frame was grabbed (in uS) - */ + * Get the next camera frame + * + * @return a Pair of the captured image and the Linux epoch of when the frame was grabbed (in uS) + */ Pair getFrame(); } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/image/StaticImageCapture.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/image/StaticImageCapture.java index e10d51e63..6fcd581f2 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/image/StaticImageCapture.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/image/StaticImageCapture.java @@ -3,13 +3,12 @@ package com.chameleonvision._2.vision.image; import com.chameleonvision._2.config.CameraCalibrationConfig; import com.chameleonvision._2.vision.camera.CameraCapture; import edu.wpi.cscore.VideoMode; -import org.apache.commons.lang3.tuple.Pair; -import org.opencv.core.Mat; -import org.opencv.imgcodecs.Imgcodecs; - import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import org.apache.commons.lang3.tuple.Pair; +import org.opencv.core.Mat; +import org.opencv.imgcodecs.Imgcodecs; public class StaticImageCapture implements CameraCapture { diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/CVPipeline.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/CVPipeline.java index ccac6cbfc..ada78af57 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/CVPipeline.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/CVPipeline.java @@ -3,10 +3,7 @@ package com.chameleonvision._2.vision.pipeline; import com.chameleonvision._2.vision.camera.CameraCapture; import org.opencv.core.Mat; -/** - * - * @param Pipeline result type - */ +/** @param Pipeline result type */ public abstract class CVPipeline { protected Mat outputMat = new Mat(); protected CameraCapture cameraCapture; @@ -28,5 +25,6 @@ public abstract class CVPipeline { public final List targets; @@ -14,7 +13,7 @@ public abstract class CVPipelineResult { public CVPipelineResult(List targets, Mat outputMat, long processTime) { this.targets = targets; hasTarget = targets != null && !targets.isEmpty(); -// this.outputMat = outputMat; + // this.outputMat = outputMat; outputMat.copyTo(this.outputMat); outputMat.release(); this.processTime = processTime; diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/Pipe.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/Pipe.java index 0cb962d0b..2ca5eec9e 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/Pipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/Pipe.java @@ -4,10 +4,8 @@ import org.apache.commons.lang3.tuple.Pair; public interface Pipe { /** - * - * @param input Input object for pipe - * @return Returns a Pair containing the process time in Nanoseconds, - * and the output object - */ + * @param input Input object for pipe + * @return Returns a Pair containing the process time in Nanoseconds, and the output object + */ Pair run(I input); } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/PipelineManager.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/PipelineManager.java index f75b89357..d88c6b7c3 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/PipelineManager.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/PipelineManager.java @@ -13,7 +13,6 @@ import com.chameleonvision.common.scripting.ScriptEventType; import com.chameleonvision.common.scripting.ScriptManager; import edu.wpi.cscore.VideoMode; import edu.wpi.first.networktables.NetworkTableEntry; - import java.util.Comparator; import java.util.HashMap; import java.util.LinkedList; @@ -28,14 +27,16 @@ public class PipelineManager { public final LinkedList pipelines = new LinkedList<>(); public final CVPipeline driverModePipeline = new DriverVisionPipeline(new CVPipelineSettings()); - public final Calibrate3dPipeline calib3dPipe = new Calibrate3dPipeline(new StandardCVPipelineSettings()); + public final Calibrate3dPipeline calib3dPipe = + new Calibrate3dPipeline(new StandardCVPipelineSettings()); private final VisionProcess parentProcess; private int lastPipelineIndex; private int currentPipelineIndex; public NetworkTableEntry ntIndexEntry; - public PipelineManager(VisionProcess visionProcess, List loadedPipelineSettings) { + public PipelineManager( + VisionProcess visionProcess, List loadedPipelineSettings) { parentProcess = visionProcess; if (loadedPipelineSettings == null || loadedPipelineSettings.size() == 0) { pipelines.add(new StandardCVPipeline("New Pipeline")); @@ -115,7 +116,7 @@ public class PipelineManager { if (currentPipelineIndex == DRIVERMODE_INDEX) { return driverModePipeline; } else if (currentPipelineIndex <= CAL_3D_INDEX) { - return calib3dPipe; + return calib3dPipe; } else { return pipelines.get(currentPipelineIndex); } @@ -135,25 +136,24 @@ public class PipelineManager { newPipeline = calib3dPipe; } else { - if (index < pipelines.size()&&index>=0) { + if (index < pipelines.size() && index >= 0) { newPipeline = pipelines.get(index); // if we're switching out of driver mode, try to set the nt entry to false parentProcess.setDriverModeEntry(false); ScriptManager.queueEvent(ScriptEventType.kLEDOn); + } else { + // TODO alert/warn user that pipeline doesnt exsits + System.err.println("Index is out of bounds"); } - else - { - //TODO alert/warn user that pipeline doesnt exsits - System.err.println("Index is out of bounds"); - } } if (newPipeline != null) { lastPipelineIndex = currentPipelineIndex; currentPipelineIndex = index; getCurrentPipeline().initPipeline(parentProcess.getCamera()); - if (ConfigManager.settings.currentCamera.equals(parentProcess.getCamera().getProperties().name)) { + if (ConfigManager.settings.currentCamera.equals( + parentProcess.getCamera().getProperties().name)) { ConfigManager.settings.currentPipeline = currentPipelineIndex; HashMap pipeChange = new HashMap<>(); @@ -208,9 +208,10 @@ public class PipelineManager { public void duplicatePipeline(CVPipelineSettings pipeline, VisionProcess destinationProcess) { pipeline.index = destinationProcess.pipelineManager.pipelines.size(); pipeline.nickname += "(Copy)"; - if (destinationProcess.pipelineManager.pipelines.stream().anyMatch(c -> c.settings.nickname.equals(pipeline.nickname))){ -// throw new DuplicatedKeyException("key Already exists"); - } else{ + if (destinationProcess.pipelineManager.pipelines.stream() + .anyMatch(c -> c.settings.nickname.equals(pipeline.nickname))) { + // throw new DuplicatedKeyException("key Already exists"); + } else { destinationProcess.pipelineManager.addPipeline(pipeline); } } @@ -237,15 +238,16 @@ public class PipelineManager { getConfig().saveDriverMode(driverModePipeline.settings); } - private static final Comparator IndexComparator = (o1, o2) -> { - int o1Index = o1.settings.index; - int o2Index = o2.settings.index; + private static final Comparator IndexComparator = + (o1, o2) -> { + int o1Index = o1.settings.index; + int o2Index = o2.settings.index; - if (o1Index == o2Index) { - return 0; - } else if (o1Index < o2Index) { - return -1; - } - return 1; - }; + if (o1Index == o2Index) { + return 0; + } else if (o1Index < o2Index) { + return -1; + } + return 1; + }; } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/impl/Calibrate3dPipeline.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/impl/Calibrate3dPipeline.java index 6c474d595..7069fef2d 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/impl/Calibrate3dPipeline.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/impl/Calibrate3dPipeline.java @@ -2,26 +2,27 @@ package com.chameleonvision._2.vision.pipeline.impl; import com.chameleonvision._2.config.CameraCalibrationConfig; import com.chameleonvision._2.config.ConfigManager; -import com.chameleonvision._2.vision.pipeline.CVPipeline; import com.chameleonvision._2.vision.VisionManager; import com.chameleonvision._2.vision.camera.CameraCapture; +import com.chameleonvision._2.vision.pipeline.CVPipeline; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import edu.wpi.cscore.VideoMode; import edu.wpi.first.wpilibj.util.Units; +import java.util.ArrayList; +import java.util.List; import org.opencv.calib3d.Calib3d; import org.opencv.core.*; import org.opencv.imgproc.Imgproc; -import java.util.ArrayList; -import java.util.List; - -public class Calibrate3dPipeline extends CVPipeline { +public class Calibrate3dPipeline + extends CVPipeline { private int checkerboardSquaresHigh = 7; private int checkerboardSquaresWide = 7; - private MatOfPoint3f objP;// new MatOfPoint3f(checkerboardSquaresHigh + checkerboardSquaresWide, 3);//(checkerboardSquaresWide * checkerboardSquaresHigh, 3); + private MatOfPoint3f objP; // new MatOfPoint3f(checkerboardSquaresHigh + checkerboardSquaresWide, + // 3);//(checkerboardSquaresWide * checkerboardSquaresHigh, 3); private Size patternSize = new Size(checkerboardSquaresHigh, checkerboardSquaresWide); private Size imageSize; double checkerboardSquareSize = 1; // inches! @@ -35,7 +36,9 @@ public class Calibrate3dPipeline extends CVPipeline tvecs = new ArrayList<>(); try { - calibrationAccuracy = Calib3d.calibrateCamera(objpoints, imgpoints, imageSize, cameraMatrix, distortionCoeffs, rvecs, tvecs); - } catch(Exception e) { + calibrationAccuracy = + Calib3d.calibrateCamera( + objpoints, imgpoints, imageSize, cameraMatrix, distortionCoeffs, rvecs, tvecs); + } catch (Exception e) { System.err.println("Camera calibration failed!"); initPipeline(cameraCapture); return false; @@ -139,32 +144,35 @@ public class Calibrate3dPipeline extends CVPipeline { +public class DriverVisionPipeline + extends CVPipeline { private RotateFlipPipe rotateFlipPipe; private Draw2dCrosshairPipe drawCrosshairPipe; - private Draw2dCrosshairPipe.Draw2dCrosshairPipeSettings crosshairPipeSettings = new Draw2dCrosshairPipe.Draw2dCrosshairPipeSettings(); + private Draw2dCrosshairPipe.Draw2dCrosshairPipeSettings crosshairPipeSettings = + new Draw2dCrosshairPipe.Draw2dCrosshairPipeSettings(); private final MemoryManager memoryManager = new MemoryManager(200, 20000); @@ -31,8 +31,9 @@ public class DriverVisionPipeline extends CVPipeline rotateFlipResult = rotateFlipPipe.run(inputMat); - Pair draw2dCrosshairResult = drawCrosshairPipe.run(Pair.of(rotateFlipResult.getLeft(),null)); + Pair draw2dCrosshairResult = + drawCrosshairPipe.run(Pair.of(rotateFlipResult.getLeft(), null)); memoryManager.run(); return new DriverPipelineResult(null, draw2dCrosshairResult.getLeft(), 0); diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/impl/StandardCVPipelineSettings.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/impl/StandardCVPipelineSettings.java index 6a827983d..9def4d7ba 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/impl/StandardCVPipelineSettings.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/impl/StandardCVPipelineSettings.java @@ -32,18 +32,17 @@ public class StandardCVPipelineSettings extends CVPipelineSettings { // 3d stuff public MatOfPoint3f targetCornerMat = new MatOfPoint3f(); public Number accuracy = 5; - private static MatOfPoint3f hexTargetMat = new MatOfPoint3f( - new Point3(-19.625, 0, 0), - new Point3(-9.819867, -17, 0), - new Point3(9.819867, -17, 0), - new Point3(19.625, 0, 0) - ); + private static MatOfPoint3f hexTargetMat = + new MatOfPoint3f( + new Point3(-19.625, 0, 0), + new Point3(-9.819867, -17, 0), + new Point3(9.819867, -17, 0), + new Point3(19.625, 0, 0)); public StandardCVPipelineSettings() { super(); hexTargetMat.copyTo(targetCornerMat); } - public boolean is3D = false; } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/BlurPipe.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/BlurPipe.java index e99bcf434..6a480b200 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/BlurPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/BlurPipe.java @@ -23,18 +23,19 @@ public class BlurPipe implements Pipe { public Pair run(Mat input) { long processStartNanos = System.nanoTime(); -// if (blurSize > 0) { -// input.copyTo(processBuffer); -// try { -// Imgproc.blur(processBuffer, processBuffer, new Size(blurSize, blurSize)); -// processBuffer.copyTo(outputMat); -// processBuffer.release(); -// } catch (CvException e) { -// System.err.println("(BlurPipe) Exception thrown by OpenCV: \n" + e.getMessage()); -// } -// } else { -// input.copyTo(outputMat); -// } + // if (blurSize > 0) { + // input.copyTo(processBuffer); + // try { + // Imgproc.blur(processBuffer, processBuffer, new Size(blurSize, blurSize)); + // processBuffer.copyTo(outputMat); + // processBuffer.release(); + // } catch (CvException e) { + // System.err.println("(BlurPipe) Exception thrown by OpenCV: \n" + + // e.getMessage()); + // } + // } else { + // input.copyTo(outputMat); + // } long processTime = System.nanoTime() - processStartNanos; return Pair.of(input, processTime); diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/Collect2dTargetsPipe.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/Collect2dTargetsPipe.java index 74a8c6ab3..8d4be1201 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/Collect2dTargetsPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/Collect2dTargetsPipe.java @@ -7,15 +7,16 @@ import com.chameleonvision._2.vision.enums.TargetRegion; import com.chameleonvision._2.vision.pipeline.Pipe; import com.chameleonvision._2.vision.pipeline.impl.StandardCVPipeline; import com.chameleonvision.common.util.numbers.DoubleCouple; +import java.util.ArrayList; +import java.util.List; import org.apache.commons.lang3.tuple.Pair; import org.apache.commons.math3.util.FastMath; import org.opencv.core.Point; -import java.util.ArrayList; -import java.util.List; - -public class Collect2dTargetsPipe implements Pipe, CaptureStaticProperties>, List> { - +public class Collect2dTargetsPipe + implements Pipe< + Pair, CaptureStaticProperties>, + List> { private CaptureStaticProperties camProps; private CalibrationMode calibrationMode; @@ -26,11 +27,32 @@ public class Collect2dTargetsPipe implements Pipe targets = new ArrayList<>(); private Point[] vertices = new Point[4]; - public Collect2dTargetsPipe(CalibrationMode calibrationMode, TargetRegion targetRegion, TargetOrientation targetOrientation, DoubleCouple calibrationPoint, double calibrationM, double calibrationB, CaptureStaticProperties camProps) { - setConfig(calibrationMode, targetRegion, targetOrientation, calibrationPoint, calibrationM, calibrationB, camProps); + public Collect2dTargetsPipe( + CalibrationMode calibrationMode, + TargetRegion targetRegion, + TargetOrientation targetOrientation, + DoubleCouple calibrationPoint, + double calibrationM, + double calibrationB, + CaptureStaticProperties camProps) { + setConfig( + calibrationMode, + targetRegion, + targetOrientation, + calibrationPoint, + calibrationM, + calibrationB, + camProps); } - public void setConfig(CalibrationMode calibrationMode, TargetRegion targetRegion, TargetOrientation targetOrientation, DoubleCouple calibrationPoint, double calibrationM, double calibrationB, CaptureStaticProperties camProps) { + public void setConfig( + CalibrationMode calibrationMode, + TargetRegion targetRegion, + TargetOrientation targetOrientation, + DoubleCouple calibrationPoint, + double calibrationM, + double calibrationB, + CaptureStaticProperties camProps) { this.calibrationMode = calibrationMode; this.calibrationPoint = calibrationPoint; this.calibrationM = calibrationM; @@ -41,7 +63,8 @@ public class Collect2dTargetsPipe implements Pipe, Long> run(Pair, CaptureStaticProperties> inputPair) { + public Pair, Long> run( + Pair, CaptureStaticProperties> inputPair) { long processStartNanos = System.nanoTime(); targets.clear(); @@ -64,22 +87,26 @@ public class Collect2dTargetsPipe implements Pipe>, Mat> { +public class Draw2dContoursPipe + implements Pipe>, Mat> { private final Draw2dContoursSettings settings; private CaptureStaticProperties camProps; @@ -27,13 +27,12 @@ public class Draw2dContoursPipe implements Pipe 0) { for (int i = 0; i < input.getRight().size(); i++) { - if (i != 0 && !settings.showMultiple){ + if (i != 0 && !settings.showMultiple) { break; } StandardCVPipeline.TrackedTarget target = input.getRight().get(i); @@ -61,31 +60,44 @@ public class Draw2dContoursPipe implements Pipe>, Mat> { -public class Draw2dCrosshairPipe implements Pipe>, Mat> { - - //Settings + // Settings private Draw2dCrosshairPipeSettings crosshairSettings; private CalibrationMode calibrationMode; private DoubleCouple calibrationPoint; private double calibrationM, calibrationB; - - private Point xMax = new Point(), xMin = new Point(), yMax = new Point(), yMin = new Point(); - public Draw2dCrosshairPipe(Draw2dCrosshairPipeSettings crosshairSettings, CalibrationMode calibrationMode, DoubleCouple calibrationPoint, double calibrationM, double calibrationB) { + public Draw2dCrosshairPipe( + Draw2dCrosshairPipeSettings crosshairSettings, + CalibrationMode calibrationMode, + DoubleCouple calibrationPoint, + double calibrationM, + double calibrationB) { setConfig(crosshairSettings, calibrationMode, calibrationPoint, calibrationM, calibrationB); } - public void setConfig(Draw2dCrosshairPipeSettings crosshairSettings, CalibrationMode calibrationMode, DoubleCouple calibrationPoint, double calibrationM, double calibrationB) { + public void setConfig( + Draw2dCrosshairPipeSettings crosshairSettings, + CalibrationMode calibrationMode, + DoubleCouple calibrationPoint, + double calibrationM, + double calibrationB) { this.crosshairSettings = crosshairSettings; this.calibrationMode = calibrationMode; this.calibrationPoint = calibrationPoint; @@ -40,7 +48,7 @@ public class Draw2dCrosshairPipe implements Pipe run(Pair> inputPair) { long processStartNanos = System.nanoTime(); Mat image = inputPair.getLeft(); -// List targets = inputPair.getRight(); + // List targets = inputPair.getRight(); double x, y; double scale = image.cols() / 32.0; @@ -50,28 +58,37 @@ public class Draw2dCrosshairPipe implements Pipe>, Mat> { +public class DrawSolvePNPPipe + implements Pipe>, Mat> { private MatOfPoint3f boxCornerMat = new MatOfPoint3f(); @@ -25,22 +25,22 @@ public class DrawSolvePNPPipe implements Pipe new Point3(it.x, it.y, it.z + 6)).collect(Collectors.toList()); + var auxList = + list.stream().map(it -> new Point3(it.x, it.y, it.z + 6)).collect(Collectors.toList()); var finalList = new ArrayList<>(list); finalList.addAll(auxList); boxCornerMat.fromList(finalList); } - public void setConfig(StandardCVPipelineSettings settings) { setBox(settings.targetCornerMat); } @@ -71,7 +71,15 @@ public class DrawSolvePNPPipe implements Pipe { long processStartNanos = System.nanoTime(); if (erode || dilate) { -// input.copyTo(processBuffer); + // input.copyTo(processBuffer); if (erode) { Imgproc.erode(input, input, kernel); @@ -39,10 +39,10 @@ public class ErodeDilatePipe implements Pipe { Imgproc.dilate(input, input, kernel); } -// processBuffer.copyTo(outputMat); -// processBuffer.release(); + // processBuffer.copyTo(outputMat); + // processBuffer.release(); } else { -// input.copyTo(outputMat); + // input.copyTo(outputMat); } long processTime = System.nanoTime() - processStartNanos; diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/FilterContoursPipe.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/FilterContoursPipe.java index 85372b8a5..10d8c44a8 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/FilterContoursPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/FilterContoursPipe.java @@ -5,15 +5,12 @@ import com.chameleonvision._2.vision.pipeline.Pipe; import com.chameleonvision.common.util.math.MathUtils; import com.chameleonvision.common.util.numbers.DoubleCouple; import com.chameleonvision.common.vision.opencv.Contour; -import org.apache.commons.lang3.tuple.Pair; -import org.opencv.core.MatOfPoint; -import org.opencv.core.MatOfPoint2f; -import org.opencv.core.Rect; -import org.opencv.core.RotatedRect; -import org.opencv.imgproc.Imgproc; - import java.util.ArrayList; import java.util.List; +import org.apache.commons.lang3.tuple.Pair; +import org.opencv.core.MatOfPoint; +import org.opencv.core.Rect; +import org.opencv.core.RotatedRect; public class FilterContoursPipe implements Pipe, List> { @@ -24,14 +21,22 @@ public class FilterContoursPipe implements Pipe, List> private List filteredContours = new ArrayList<>(); - public FilterContoursPipe(DoubleCouple area, DoubleCouple ratio, DoubleCouple extent, CaptureStaticProperties camProps) { + public FilterContoursPipe( + DoubleCouple area, + DoubleCouple ratio, + DoubleCouple extent, + CaptureStaticProperties camProps) { this.area = area; this.ratio = ratio; this.extent = extent; this.camProps = camProps; } - public void setConfig(DoubleCouple area, DoubleCouple ratio, DoubleCouple extent, CaptureStaticProperties camProps) { + public void setConfig( + DoubleCouple area, + DoubleCouple ratio, + DoubleCouple extent, + CaptureStaticProperties camProps) { this.area = area; this.ratio = ratio; this.extent = extent; @@ -61,7 +66,7 @@ public class FilterContoursPipe implements Pipe, List> // AspectRatio filtering Rect boundingRect = realContour.getBoundingRect(); - double aspectRatio = ((double)boundingRect.width / boundingRect.height); + double aspectRatio = ((double) boundingRect.width / boundingRect.height); if (aspectRatio < ratio.getFirst() || aspectRatio > ratio.getSecond()) { return; } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/FindContoursPipe.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/FindContoursPipe.java index 374a82976..1966855f9 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/FindContoursPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/FindContoursPipe.java @@ -2,15 +2,14 @@ package com.chameleonvision._2.vision.pipeline.pipes; import com.chameleonvision._2.vision.pipeline.Pipe; import com.chameleonvision.common.vision.opencv.Contour; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; import org.apache.commons.lang3.tuple.Pair; import org.opencv.core.Mat; import org.opencv.core.MatOfPoint; import org.opencv.imgproc.Imgproc; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - public class FindContoursPipe implements Pipe> { private List foundContours = new ArrayList<>(); @@ -23,9 +22,11 @@ public class FindContoursPipe implements Pipe> { foundContours.clear(); - Imgproc.findContours(input, foundContours, new Mat(), Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_TC89_L1); + Imgproc.findContours( + input, foundContours, new Mat(), Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_TC89_L1); long processTime = System.nanoTime() - processStartNanos; - return Pair.of(foundContours.stream().map(Contour::new).collect(Collectors.toList()), processTime); + return Pair.of( + foundContours.stream().map(Contour::new).collect(Collectors.toList()), processTime); } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/GroupContoursPipe.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/GroupContoursPipe.java index 981186758..243e2db10 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/GroupContoursPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/GroupContoursPipe.java @@ -5,17 +5,17 @@ import com.chameleonvision._2.vision.enums.TargetIntersection; import com.chameleonvision._2.vision.pipeline.Pipe; import com.chameleonvision._2.vision.pipeline.impl.StandardCVPipeline; import com.chameleonvision.common.util.math.MathUtils; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; import org.apache.commons.lang3.tuple.Pair; import org.opencv.core.*; import org.opencv.imgproc.Imgproc; import org.opencv.imgproc.Moments; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; - -public class GroupContoursPipe implements Pipe, List> { +public class GroupContoursPipe + implements Pipe, List> { private static final Comparator sortByMomentsX = Comparator.comparingDouble(GroupContoursPipe::calcMomentsX); @@ -55,76 +55,78 @@ public class GroupContoursPipe implements Pipe, List { - contourBuffer.fromArray(c.toArray()); - if (contourBuffer.cols() != 0 && contourBuffer.rows() != 0) { - RotatedRect rect = Imgproc.minAreaRect(contourBuffer); - Rect boundingRect = Imgproc.boundingRect(contourBuffer); - var target = new StandardCVPipeline.TrackedTarget(); - target.minAreaRect = rect; - target.rawContour = contourBuffer; - target.boundingRect = boundingRect; - groupedContours.add(target); - } - }); - break; - } - case Dual: { - for (var i = 0; i < input.size(); i++) { - List finalContourList = new ArrayList<>(input.get(i).toList()); - - try { - MatOfPoint firstContour = input.get(i); - MatOfPoint secondContour = input.get(i + 1); - - if (isIntersecting(firstContour, secondContour)) { - finalContourList.addAll(secondContour.toList()); - } else { - finalContourList.clear(); - continue; - } - - intersectMatA.release(); - intersectMatB.release(); - - contourBuffer.fromList(finalContourList); - - if (contourBuffer.cols() != 0 && contourBuffer.rows() != 0) { - RotatedRect rect = Imgproc.minAreaRect(contourBuffer); - Rect boundingRect = Imgproc.boundingRect(contourBuffer); - var target = new StandardCVPipeline.TrackedTarget(); - target.minAreaRect = rect; - target.boundingRect = boundingRect; - // find left and right bouding rectangles - target.leftRightDualTargetPair = - Pair.of(Imgproc.boundingRect(firstContour), - Imgproc.boundingRect(secondContour)); - - // find left and right min area rectangles - tempRectMat.fromArray(firstContour.toArray()); - var minAreaRect1 = Imgproc.minAreaRect(tempRectMat); - tempRectMat.fromArray(secondContour.toArray()); - var minAreaRect2 = Imgproc.minAreaRect(tempRectMat); - target.leftRightRotatedRect = - Pair.of(minAreaRect1, minAreaRect2); - - target.rawContour = contourBuffer; - - groupedContours.add(target); - - firstContour.release(); - secondContour.release(); - - // skip the next contour because it's been grouped already - i += 1; - } - } catch (IndexOutOfBoundsException e) { - finalContourList.clear(); - } + case Single: + { + input.forEach( + c -> { + contourBuffer.fromArray(c.toArray()); + if (contourBuffer.cols() != 0 && contourBuffer.rows() != 0) { + RotatedRect rect = Imgproc.minAreaRect(contourBuffer); + Rect boundingRect = Imgproc.boundingRect(contourBuffer); + var target = new StandardCVPipeline.TrackedTarget(); + target.minAreaRect = rect; + target.rawContour = contourBuffer; + target.boundingRect = boundingRect; + groupedContours.add(target); + } + }); + break; + } + case Dual: + { + for (var i = 0; i < input.size(); i++) { + List finalContourList = new ArrayList<>(input.get(i).toList()); + + try { + MatOfPoint firstContour = input.get(i); + MatOfPoint secondContour = input.get(i + 1); + + if (isIntersecting(firstContour, secondContour)) { + finalContourList.addAll(secondContour.toList()); + } else { + finalContourList.clear(); + continue; + } + + intersectMatA.release(); + intersectMatB.release(); + + contourBuffer.fromList(finalContourList); + + if (contourBuffer.cols() != 0 && contourBuffer.rows() != 0) { + RotatedRect rect = Imgproc.minAreaRect(contourBuffer); + Rect boundingRect = Imgproc.boundingRect(contourBuffer); + var target = new StandardCVPipeline.TrackedTarget(); + target.minAreaRect = rect; + target.boundingRect = boundingRect; + // find left and right bouding rectangles + target.leftRightDualTargetPair = + Pair.of( + Imgproc.boundingRect(firstContour), Imgproc.boundingRect(secondContour)); + + // find left and right min area rectangles + tempRectMat.fromArray(firstContour.toArray()); + var minAreaRect1 = Imgproc.minAreaRect(tempRectMat); + tempRectMat.fromArray(secondContour.toArray()); + var minAreaRect2 = Imgproc.minAreaRect(tempRectMat); + target.leftRightRotatedRect = Pair.of(minAreaRect1, minAreaRect2); + + target.rawContour = contourBuffer; + + groupedContours.add(target); + + firstContour.release(); + secondContour.release(); + + // skip the next contour because it's been grouped already + i += 1; + } + } catch (IndexOutOfBoundsException e) { + finalContourList.clear(); + } + } + break; } - break; - } } } @@ -160,36 +162,40 @@ public class GroupContoursPipe implements Pipe, List massY) { + case Down: + { + if (intersectionY > massY) { return true; - } + } - break; - } - case Left: { - if (intersectionX < massX) { + break; + } + case Left: + { + if (intersectionX < massX) { - return true; + return true; + } + break; } - break; - } - case Right: { - if (intersectionX > massX) { - return true; + case Right: + { + if (intersectionX > massX) { + return true; + } + break; } - break; - } } return false; } catch (Exception e) { return false; } } -} \ No newline at end of file +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/HsvPipe.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/HsvPipe.java index be879d236..a42b4a8be 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/HsvPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/HsvPipe.java @@ -43,4 +43,3 @@ public class HsvPipe implements Pipe { return Pair.of(outputMat, processTime); } } - diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/OutputMatPipe.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/OutputMatPipe.java index 63113e7f2..f4de7b263 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/OutputMatPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/OutputMatPipe.java @@ -22,11 +22,10 @@ public class OutputMatPipe implements Pipe, Mat> { } /** - * - * @param input Input object for pipe - * Left is raw camera mat (8UC3), Right is HSV threshold mat (8UC1) - * @return Returns desired output Mat, and processing time in nanoseconds - */ + * @param input Input object for pipe Left is raw camera mat (8UC3), Right is HSV threshold mat + * (8UC1) + * @return Returns desired output Mat, and processing time in nanoseconds + */ @Override public Pair run(Pair input) { long processStartNanos = System.nanoTime(); diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/RotateFlipPipe.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/RotateFlipPipe.java index cc7c27585..dbb2c2e22 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/RotateFlipPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/RotateFlipPipe.java @@ -1,8 +1,8 @@ package com.chameleonvision._2.vision.pipeline.pipes; -import com.chameleonvision._2.vision.pipeline.Pipe; import com.chameleonvision._2.vision.enums.ImageFlipMode; import com.chameleonvision._2.vision.enums.ImageRotationMode; +import com.chameleonvision._2.vision.pipeline.Pipe; import org.apache.commons.lang3.tuple.Pair; import org.opencv.core.Core; import org.opencv.core.Mat; @@ -33,7 +33,7 @@ public class RotateFlipPipe implements Pipe { boolean shouldRotate = !rotation.equals(ImageRotationMode.DEG_0); if (shouldFlip || shouldRotate) { -// input.copyTo(processBuffer); + // input.copyTo(processBuffer); if (shouldFlip) { Core.flip(input, input, flip.value); @@ -43,10 +43,10 @@ public class RotateFlipPipe implements Pipe { Core.rotate(input, input, rotation.value); } -// processBuffer.copyTo(outputMat); -// processBuffer.release(); + // processBuffer.copyTo(outputMat); + // processBuffer.release(); } else { -// input.copyTo(outputMat); + // input.copyTo(outputMat); } long processTime = System.nanoTime() - processStartNanos; diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/SolvePNPPipe.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/SolvePNPPipe.java index 61a20da56..bd2d4f6ef 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/SolvePNPPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/SolvePNPPipe.java @@ -1,12 +1,5 @@ package com.chameleonvision._2.vision.pipeline.pipes; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Comparator; -import java.util.List; -import java.util.NoSuchElementException; -import java.util.stream.Collectors; - import com.chameleonvision._2.config.CameraCalibrationConfig; import com.chameleonvision._2.vision.pipeline.Pipe; import com.chameleonvision._2.vision.pipeline.impl.StandardCVPipeline; @@ -14,6 +7,12 @@ import com.chameleonvision._2.vision.pipeline.impl.StandardCVPipelineSettings; import edu.wpi.first.wpilibj.geometry.Pose2d; import edu.wpi.first.wpilibj.geometry.Rotation2d; import edu.wpi.first.wpilibj.geometry.Translation2d; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.stream.Collectors; import org.apache.commons.lang3.tuple.Pair; import org.apache.commons.math3.util.FastMath; import org.opencv.calib3d.Calib3d; @@ -30,477 +29,506 @@ import org.opencv.core.Size; import org.opencv.core.TermCriteria; import org.opencv.imgproc.Imgproc; -/** - * Handles detecting target corners and calculating robot-relative pose. - */ -public class SolvePNPPipe implements Pipe, Mat>, - List> { +/** Handles detecting target corners and calculating robot-relative pose. */ +public class SolvePNPPipe + implements Pipe< + Pair, Mat>, List> { - private Double tilt_angle; - private MatOfPoint3f objPointsMat = new MatOfPoint3f(); - private Mat rVec = new Mat(); - private Mat tVec = new Mat(); - private Mat rodriguez = new Mat(); - private Mat pzero_world = new Mat(); - private Mat cameraMatrix = new Mat(); - Mat rot_inv = new Mat(); - Mat kMat = new Mat(); - private MatOfDouble distortionCoefficients = new MatOfDouble(); - private List targetList = new ArrayList<>(); - Comparator leftRightComparator = Comparator.comparingDouble(point -> point.x); - Comparator verticalComparator = Comparator.comparingDouble(point -> point.y); - private double distanceDivisor = 1.0; - Mat scaledTvec = new Mat(); - MatOfPoint2f boundingBoxResultMat = new MatOfPoint2f(); - MatOfPoint2f polyOutput = new MatOfPoint2f(); - private Mat greyImg = new Mat(); - private double accuracyPercentage = 0.2; + private Double tilt_angle; + private MatOfPoint3f objPointsMat = new MatOfPoint3f(); + private Mat rVec = new Mat(); + private Mat tVec = new Mat(); + private Mat rodriguez = new Mat(); + private Mat pzero_world = new Mat(); + private Mat cameraMatrix = new Mat(); + Mat rot_inv = new Mat(); + Mat kMat = new Mat(); + private MatOfDouble distortionCoefficients = new MatOfDouble(); + private List targetList = new ArrayList<>(); + Comparator leftRightComparator = Comparator.comparingDouble(point -> point.x); + Comparator verticalComparator = Comparator.comparingDouble(point -> point.y); + private double distanceDivisor = 1.0; + Mat scaledTvec = new Mat(); + MatOfPoint2f boundingBoxResultMat = new MatOfPoint2f(); + MatOfPoint2f polyOutput = new MatOfPoint2f(); + private Mat greyImg = new Mat(); + private double accuracyPercentage = 0.2; - /** - * @param settings unused :bolb: - * @param calibration the camera intrinsics and extrinsics - * @param tilt The pitch of the camera relative to horzontal. used to account for - * distances in calculate pose - */ - public SolvePNPPipe(StandardCVPipelineSettings settings, CameraCalibrationConfig calibration, - Rotation2d tilt) { - super(); - setCameraCoeffs(calibration); -// setBoundingBoxTarget(settings.targetWidth, settings.targetHeight); - // TODO add proper year differentiation - set2020Target(true); + /** + * @param settings unused :bolb: + * @param calibration the camera intrinsics and extrinsics + * @param tilt The pitch of the camera relative to horzontal. used to account for distances in + * calculate pose + */ + public SolvePNPPipe( + StandardCVPipelineSettings settings, CameraCalibrationConfig calibration, Rotation2d tilt) { + super(); + setCameraCoeffs(calibration); + // setBoundingBoxTarget(settings.targetWidth, settings.targetHeight); + // TODO add proper year differentiation + set2020Target(true); - this.tilt_angle = tilt.getRadians(); - } - - public void set2020Target(boolean isHighGoal) { - if (isHighGoal) { - // tl, bl, br, tr is the order - List corners = List.of( - - new Point3(-19.625, 0, 0), - new Point3(-9.819867, -17, 0), - new Point3(9.819867, -17, 0), - new Point3(19.625, 0, 0)); - setObjectCorners(corners); - } else { - setBoundingBoxTarget(7, 11); - } - } - - public void setBoundingBoxTarget(double targetWidth, double targetHeight) { - // order is left top, left bottom, right bottom, right top - - List corners = List.of( - new Point3(-targetWidth / 2.0, targetHeight / 2.0, 0.0), - new Point3(-targetWidth / 2.0, -targetHeight / 2.0, 0.0), - new Point3(targetWidth / 2.0, -targetHeight / 2.0, 0.0), - new Point3(targetWidth / 2.0, targetHeight / 2.0, 0.0) - ); - setObjectCorners(corners); - } - - public void setObjectCorners(List objectCorners) { - objPointsMat.release(); - objPointsMat = new MatOfPoint3f(); - objPointsMat.fromList(objectCorners); - } - - public void setConfig(StandardCVPipelineSettings settings, CameraCalibrationConfig camConfig, - Rotation2d tilt) { - setCameraCoeffs(camConfig); -// setBoundingBoxTarget(settings.targetWidth, settings.targetHeight); - // TODO add proper year differentiation - tilt_angle = tilt.getRadians(); - this.objPointsMat = settings.targetCornerMat; - this.accuracyPercentage = settings.accuracy.doubleValue(); - } - - private void setCameraCoeffs(CameraCalibrationConfig settings) { - if (settings == null) { - System.err.println("SolvePNP can only run on a calibrated resolution, and this one is not!" + - " Please calibrate to use solvePNP."); - return; - } - if (cameraMatrix != settings.getCameraMatrixAsMat()) { - cameraMatrix.release(); - settings.getCameraMatrixAsMat().copyTo(cameraMatrix); - } - if (distortionCoefficients != settings.getDistortionCoeffsAsMat()) { - distortionCoefficients.release(); - settings.getDistortionCoeffsAsMat().copyTo(distortionCoefficients); - } - this.distanceDivisor = settings.squareSize; - } - - @Override - public Pair, Long> run(Pair, Mat> imageTargetPair) { - long processStartNanos = System.nanoTime(); - var targets = imageTargetPair.getLeft(); - var image = imageTargetPair.getRight(); - Imgproc.cvtColor(image, greyImg, Imgproc.COLOR_BGR2GRAY); - targetList.clear(); - for (var target : targets) { - MatOfPoint2f corners; - // if it's a dual target use 2019, but default to 2020 - if (target.leftRightRotatedRect == null) { - corners = find2020VisionTarget(target, accuracyPercentage);//, imageTargetPair.getRight - // ()); //find2020VisionTarget(target);// (target.leftRightDualTargetPair != null) ? - // findCorner2019(target) : findBoundingBoxCorners(target); - } else { - corners = findCorner2019(target); - } -// var corners = findCorner2019(target); - if (corners == null) continue; - - // convert the corners into a Pose2d - var pose = calculatePose(corners, target); - targetList.add(pose); // TODO null check null poses. DO NOT ADD A NULL CHECK HERE, otherwise - // the order will be wrong. - } - long processTime = System.nanoTime() - processStartNanos; - return Pair.of(targetList, processTime); - } - - /** - * basically we split the target's two tapes, find the min area rectangle for each, and take - * the outermost 4 corners out of the 2 rectangles - * - * @param target the target to use - * @return the 4 outermost corners. - */ - private MatOfPoint2f findCorner2019(StandardCVPipeline.TrackedTarget target) { - if (target.leftRightDualTargetPair == null) return null; - - var left = target.leftRightDualTargetPair.getLeft(); - var right = target.leftRightDualTargetPair.getRight(); - - // flip if the "left" target is to the right - if (left.x > right.x) { - var temp = left; - left = right; - right = temp; + this.tilt_angle = tilt.getRadians(); } - var points = new MatOfPoint2f(); - points.fromArray( - new Point(left.x, left.y + left.height), - new Point(left.x, left.y), - new Point(right.x + right.width, right.y), - new Point(right.x + right.width, right.y + right.height) - ); - return points; - } - - MatOfPoint2f target2020ResultMat = new MatOfPoint2f(); - - private double distanceBetween(Point a, Point b) { - return FastMath.sqrt(FastMath.pow(a.x - b.x, 2) + FastMath.pow(a.y - b.y, 2)); - } - - /** - * Find the target using the outermost tape corners and a 2020 target. Uses approxPolyDP to - * approximate the target outline. - * - * @param target the target. - * @return The four outermost tape corners. - */ - private MatOfPoint2f find2020VisionTarget(StandardCVPipeline.TrackedTarget target, - double accuracyPercentage) { - if (target.rawContour.cols() < 1) return null; - - var centroid = target.minAreaRect.center; - Comparator distanceProvider = - Comparator.comparingDouble((Point point) -> FastMath.sqrt(FastMath.pow(centroid.x - point.x, 2) + FastMath.pow(centroid.y - point.y, 2))); - - // algorithm from team 4915 - - // Contour perimeter - var peri = Imgproc.arcLength(target.rawContour, true); - // approximating a shape around the contours - // Can be tuned to allow/disallow hulls - // Approx is the number of vertices - // Ramer–Douglas–Peucker algorithm - // we want a number between 0 and 0.16 out of a percentage from 0 to 100 - // so take accuracy and divide by 600 - Imgproc.approxPolyDP(target.rawContour, polyOutput, accuracyPercentage / 600.0 * peri, true); - - var area = Imgproc.moments(polyOutput); - -// if (area.get_m00() < 200) { -// return null; -// } - - var polyList = polyOutput.toList(); - - polyOutput.copyTo(target.approxPoly); - - // left top, left bottom, right bottom, right top - var boundingBoxCorners = findBoundingBoxCorners(target).toList(); - - try { - - // top left and top right are the poly corners closest to the bouding box tl and tr - var tl = polyList.stream().min(Comparator.comparingDouble((Point p) -> distanceBetween(p, - boundingBoxCorners.get(0)))).get(); - var tr = polyList.stream().min(Comparator.comparingDouble((Point p) -> distanceBetween(p, - boundingBoxCorners.get(3)))).get(); - - // bottom left and bottom right have to be in the correct quadrant and are the furthest - // from the center - var bl = - polyList.stream().filter(point -> point.x < centroid.x && point.y > centroid.y).max(distanceProvider).get(); - var br = - polyList.stream().filter(point -> point.x > centroid.x && point.y > centroid.y).max(distanceProvider).get(); - -// polyList = new ArrayList<>(polyList); -// polyList.removeAll(List.of(tl, tr, bl, br)); -// -// var tl2 = polyList.stream().min(Comparator.comparingDouble((Point p) -> -// distanceBetween(p, boundingBoxCorners.get(0)))).get(); -// var tr2 = polyList.stream().min(Comparator.comparingDouble((Point p) -> -// distanceBetween(p, boundingBoxCorners.get(3)))).get(); -// -// var bl2 = polyList.stream().filter(point -> point.x < centroid.x && point.y > -// centroid.y).max(distanceProvider).get(); -// var br2 = polyList.stream().filter(point -> point.x > centroid.x && point.y > -// centroid.y).max(distanceProvider).get(); - - target2020ResultMat.release(); - target2020ResultMat.fromList(List.of(tl, bl, br, tr));//, tr2, br2, bl2, tl2)); - - return target2020ResultMat; - } catch (NoSuchElementException e) { - return null; - } - } - - /** - * Find the target using the outermost tape corners and a dual target. - * - * @param target the target. - * @return The four outermost tape corners. - */ - private MatOfPoint2f findDualTargetCornerMinAreaRect(StandardCVPipeline.TrackedTarget target) { - if (target.leftRightRotatedRect == null) return null; - - var centroid = target.minAreaRect.center; - Comparator distanceProvider = - Comparator.comparingDouble((Point point) -> FastMath.sqrt(FastMath.pow(centroid.x - point.x, 2) + FastMath.pow(centroid.y - point.y, 2))); - - var left = target.leftRightRotatedRect.getLeft(); - var right = target.leftRightRotatedRect.getRight(); - - // flip if the "left" target is to the right - if (left.center.x > right.center.x) { - var temp = left; - left = right; - right = temp; + public void set2020Target(boolean isHighGoal) { + if (isHighGoal) { + // tl, bl, br, tr is the order + List corners = + List.of( + new Point3(-19.625, 0, 0), + new Point3(-9.819867, -17, 0), + new Point3(9.819867, -17, 0), + new Point3(19.625, 0, 0)); + setObjectCorners(corners); + } else { + setBoundingBoxTarget(7, 11); + } } - var leftPoints = new Point[4]; - left.points(leftPoints); - var rightPoints = new Point[4]; - right.points(rightPoints); - ArrayList combinedList = new ArrayList<>(List.of(leftPoints)); - combinedList.addAll(List.of(rightPoints)); + public void setBoundingBoxTarget(double targetWidth, double targetHeight) { + // order is left top, left bottom, right bottom, right top - // start looking in the top left quadrant - var tl = - combinedList.stream().filter(point -> point.x < centroid.x && point.y < centroid.y).max(distanceProvider).get(); - var tr = - combinedList.stream().filter(point -> point.x > centroid.x && point.y < centroid.y).max(distanceProvider).get(); - var bl = - combinedList.stream().filter(point -> point.x < centroid.x && point.y > centroid.y).max(distanceProvider).get(); - var br = - combinedList.stream().filter(point -> point.x > centroid.x && point.y > centroid.y).max(distanceProvider).get(); - - boundingBoxResultMat.release(); - boundingBoxResultMat.fromList(List.of(tl, bl, br, tr)); - - return boundingBoxResultMat; - } - - /** - * @param target the target to find the corners of. - * @return the corners. left top, left bottom, right bottom, right top - */ - private MatOfPoint2f findBoundingBoxCorners(StandardCVPipeline.TrackedTarget target) { - -// List> list = new ArrayList<>(); -// // find the corners based on the bounding box -// // order is left top, left bottom, right bottom, right top - - // extract the corners - var points = new Point[4]; - target.minAreaRect.points(points); - - // find the tl/tr/bl/br corners - // first, min by left/right - var list_ = Arrays.asList(points); - list_.sort(leftRightComparator); - // of this, we now have left and right - // sort to get top and bottom - var left = new ArrayList<>(List.of(list_.get(0), list_.get(1))); - left.sort(verticalComparator); - 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); - - boundingBoxResultMat.release(); - boundingBoxResultMat.fromList(List.of(tl, bl, br, tr)); - - return boundingBoxResultMat; - } - - MatOfPoint2f goodFeatureToTrackRetval = new MatOfPoint2f(); - - private MatOfPoint2f refineCornersByBestTrack(MatOfPoint2f corners, Mat greyImg, - StandardCVPipeline.TrackedTarget target) { - - MatOfPoint approxf1 = new MatOfPoint(); - var origCornerList = new ArrayList<>(corners.toList()); - approxf1.fromList(origCornerList.stream() - .map(it -> new Point(it.x - target.boundingRect.x, it.y - target.boundingRect.y)) - .collect(Collectors.toList()) - ); - var croppedImage = greyImg.submat(target.boundingRect); - - Imgproc.goodFeaturesToTrack(croppedImage, approxf1, 0, 0.1, 5); - - // at this point corners is still unmodified so let's map it - List tempList = new ArrayList<>(); - - // shift all points back into global pose - var reshiftedList = - approxf1.toList().stream().map(it -> new Point(it.x + target.boundingRect.x, - it.y + target.boundingRect.y)) - .collect(Collectors.toList()); - for (Point p : origCornerList) { - // find the goodFeaturesToTrack corner closest to me - var closestPoint = - reshiftedList.stream().min(Comparator.comparingDouble(p_ -> distanceBetween(p_, p))); - if (closestPoint.isEmpty()) { - tempList.add(p); - reshiftedList.remove(p); - } else { - tempList.add(closestPoint.get()); - reshiftedList.remove(closestPoint.get()); - } + List corners = + List.of( + new Point3(-targetWidth / 2.0, targetHeight / 2.0, 0.0), + new Point3(-targetWidth / 2.0, -targetHeight / 2.0, 0.0), + new Point3(targetWidth / 2.0, -targetHeight / 2.0, 0.0), + new Point3(targetWidth / 2.0, targetHeight / 2.0, 0.0)); + setObjectCorners(corners); } - goodFeatureToTrackRetval.fromList(tempList); - return goodFeatureToTrackRetval; - } - - // Set the needed parameters to find the refined corners - Size winSize = new Size(4, 4); - Size zeroZone = new Size(-1, -1); // we don't need a zero zone - TermCriteria criteria = new TermCriteria(TermCriteria.EPS + TermCriteria.COUNT, 90, 0.001); - - private boolean shouldRefineCorners = true; - - /** - * Refine an estimated corner position using the cornerSubPixel algorithm. - *

- * TODO should this be here or before the points are chosen? - * - * @param corners the corners detected -- this mat is modified! - * @param greyImg the image taken by the camera as color - * @return the updated mat, same as the corner mat passed in. - */ - private MatOfPoint2f refineCornerEstimateSubPix(MatOfPoint2f corners, Mat greyImg) { - if (!shouldRefineCorners) return corners; // just return - Imgproc.cornerSubPix(greyImg, corners, winSize, zeroZone, criteria); - - return corners; - } - -// NetworkTableEntry tvecE = NetworkTableInstance.getDefault().getTable("SmartDashboard") -// .getEntry("tvec"); -// NetworkTableEntry rvecE = NetworkTableInstance.getDefault().getTable("SmartDashboard") -// .getEntry("rvec"); - - /** - * Calculate the pose of the vision target - * - * @param imageCornerPoints the corners we found. - * @param target the target to process, mutated. - * @return the target, with the pose2d added to it. - */ - public StandardCVPipeline.TrackedTarget calculatePose(MatOfPoint2f imageCornerPoints, - StandardCVPipeline.TrackedTarget target) { - if (objPointsMat.rows() != imageCornerPoints.rows() || cameraMatrix.rows() < 2 || distortionCoefficients.cols() < 4) { - System.err.println("can't do solvePNP with invalid params!"); - return null; + public void setObjectCorners(List objectCorners) { + objPointsMat.release(); + objPointsMat = new MatOfPoint3f(); + objPointsMat.fromList(objectCorners); } - imageCornerPoints.copyTo(target.imageCornerPoints); - - try { - Calib3d.solvePnP(objPointsMat, imageCornerPoints, cameraMatrix, distortionCoefficients, - rVec, tVec); - } catch (Exception e) { - e.printStackTrace(); - return null; + public void setConfig( + StandardCVPipelineSettings settings, CameraCalibrationConfig camConfig, Rotation2d tilt) { + setCameraCoeffs(camConfig); + // setBoundingBoxTarget(settings.targetWidth, settings.targetHeight); + // TODO add proper year differentiation + tilt_angle = tilt.getRadians(); + this.objPointsMat = settings.targetCornerMat; + this.accuracyPercentage = settings.accuracy.doubleValue(); } -// tvecE.setString(tVec.dump()); -// rvecE.setString(rVec.dump()); + private void setCameraCoeffs(CameraCalibrationConfig settings) { + if (settings == null) { + System.err.println( + "SolvePNP can only run on a calibrated resolution, and this one is not!" + + " Please calibrate to use solvePNP."); + return; + } + if (cameraMatrix != settings.getCameraMatrixAsMat()) { + cameraMatrix.release(); + settings.getCameraMatrixAsMat().copyTo(cameraMatrix); + } + if (distortionCoefficients != settings.getDistortionCoeffsAsMat()) { + distortionCoefficients.release(); + settings.getDistortionCoeffsAsMat().copyTo(distortionCoefficients); + } + this.distanceDivisor = settings.squareSize; + } - // Algorithm from team 5190 Green Hope Falcons. Can also be found in Ligerbot's vision - // whitepaper + @Override + public Pair, Long> run( + Pair, Mat> imageTargetPair) { + long processStartNanos = System.nanoTime(); + var targets = imageTargetPair.getLeft(); + var image = imageTargetPair.getRight(); + Imgproc.cvtColor(image, greyImg, Imgproc.COLOR_BGR2GRAY); + targetList.clear(); + for (var target : targets) { + MatOfPoint2f corners; + // if it's a dual target use 2019, but default to 2020 + if (target.leftRightRotatedRect == null) { + corners = find2020VisionTarget(target, accuracyPercentage); // , imageTargetPair.getRight + // ()); //find2020VisionTarget(target);// (target.leftRightDualTargetPair != null) ? + // findCorner2019(target) : findBoundingBoxCorners(target); + } else { + corners = findCorner2019(target); + } + // var corners = findCorner2019(target); + if (corners == null) continue; - // the left/right distance to the target, unchanged by tilt. Inches - var x = tVec.get(0, 0)[0]; + // convert the corners into a Pose2d + var pose = calculatePose(corners, target); + targetList.add(pose); // TODO null check null poses. DO NOT ADD A NULL CHECK HERE, otherwise + // the order will be wrong. + } + long processTime = System.nanoTime() - processStartNanos; + return Pair.of(targetList, processTime); + } - // Z distance in the flat plane is given by - // Z_field = z cos theta + y sin theta. - // Z is the distance "out" of the camera (straight forward). Inches. - var z = tVec.get(2, 0)[0] * FastMath.cos(tilt_angle) + tVec.get(1, 0)[0] * FastMath.sin(tilt_angle); + /** + * basically we split the target's two tapes, find the min area rectangle for each, and take the + * outermost 4 corners out of the 2 rectangles + * + * @param target the target to use + * @return the 4 outermost corners. + */ + private MatOfPoint2f findCorner2019(StandardCVPipeline.TrackedTarget target) { + if (target.leftRightDualTargetPair == null) return null; - Calib3d.Rodrigues(rVec, rodriguez); - Core.transpose(rodriguez, rot_inv); // rodrigurz.t() + var left = target.leftRightDualTargetPair.getLeft(); + var right = target.leftRightDualTargetPair.getRight(); - scaledTvec = matScale(tVec, -1); - Core.gemm(rot_inv, scaledTvec, 1, kMat, 0, pzero_world); + // flip if the "left" target is to the right + if (left.x > right.x) { + var temp = left; + left = right; + right = temp; + } - var angle2 = FastMath.atan2(pzero_world.get(0, 0)[0], pzero_world.get(2, 0)[0]); + var points = new MatOfPoint2f(); + points.fromArray( + new Point(left.x, left.y + left.height), + new Point(left.x, left.y), + new Point(right.x + right.width, right.y), + new Point(right.x + right.width, right.y + right.height)); + return points; + } - // target rotation is the rotation of the target relative to straight ahead. this number - // should be unchanged if the robot purely translated left/right. - var targetRotation = -angle2; // radians + MatOfPoint2f target2020ResultMat = new MatOfPoint2f(); - // We want a vector that is X forward and Y left. - // We have a Z_field (out of the camera projected onto the field), and an X left/right. - // so Z_field becomes X, and X becomes Y + private double distanceBetween(Point a, Point b) { + return FastMath.sqrt(FastMath.pow(a.x - b.x, 2) + FastMath.pow(a.y - b.y, 2)); + } - //noinspection SuspiciousNameCombination - var targetLocation = new Translation2d(z, -x).times(25.4 / 1000d / distanceDivisor); - target.cameraRelativePose = new Pose2d(targetLocation, new Rotation2d(targetRotation)); - target.rVector = rVec; - target.tVector = tVec; + /** + * Find the target using the outermost tape corners and a 2020 target. Uses approxPolyDP to + * approximate the target outline. + * + * @param target the target. + * @return The four outermost tape corners. + */ + private MatOfPoint2f find2020VisionTarget( + StandardCVPipeline.TrackedTarget target, double accuracyPercentage) { + if (target.rawContour.cols() < 1) return null; - return target; - } + var centroid = target.minAreaRect.center; + Comparator distanceProvider = + Comparator.comparingDouble( + (Point point) -> + FastMath.sqrt( + FastMath.pow(centroid.x - point.x, 2) + FastMath.pow(centroid.y - point.y, 2))); - /** - * 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 - */ - public Mat matScale(Mat src, double factor) { - Mat dst = new Mat(src.rows(), src.cols(), src.type()); - Scalar s = new Scalar(factor); // TODO check if we need to add more elements to this - Core.multiply(src, s, dst); - return dst; - } + // algorithm from team 4915 + // Contour perimeter + var peri = Imgproc.arcLength(target.rawContour, true); + // approximating a shape around the contours + // Can be tuned to allow/disallow hulls + // Approx is the number of vertices + // Ramer–Douglas–Peucker algorithm + // we want a number between 0 and 0.16 out of a percentage from 0 to 100 + // so take accuracy and divide by 600 + Imgproc.approxPolyDP(target.rawContour, polyOutput, accuracyPercentage / 600.0 * peri, true); + + var area = Imgproc.moments(polyOutput); + + // if (area.get_m00() < 200) { + // return null; + // } + + var polyList = polyOutput.toList(); + + polyOutput.copyTo(target.approxPoly); + + // left top, left bottom, right bottom, right top + var boundingBoxCorners = findBoundingBoxCorners(target).toList(); + + try { + + // top left and top right are the poly corners closest to the bouding box tl and tr + var tl = + polyList.stream() + .min( + Comparator.comparingDouble( + (Point p) -> distanceBetween(p, boundingBoxCorners.get(0)))) + .get(); + var tr = + polyList.stream() + .min( + Comparator.comparingDouble( + (Point p) -> distanceBetween(p, boundingBoxCorners.get(3)))) + .get(); + + // bottom left and bottom right have to be in the correct quadrant and are the furthest + // from the center + var bl = + polyList.stream() + .filter(point -> point.x < centroid.x && point.y > centroid.y) + .max(distanceProvider) + .get(); + var br = + polyList.stream() + .filter(point -> point.x > centroid.x && point.y > centroid.y) + .max(distanceProvider) + .get(); + + // polyList = new ArrayList<>(polyList); + // polyList.removeAll(List.of(tl, tr, bl, br)); + // + // var tl2 = polyList.stream().min(Comparator.comparingDouble((Point p) -> + // distanceBetween(p, boundingBoxCorners.get(0)))).get(); + // var tr2 = polyList.stream().min(Comparator.comparingDouble((Point p) -> + // distanceBetween(p, boundingBoxCorners.get(3)))).get(); + // + // var bl2 = polyList.stream().filter(point -> point.x < centroid.x && point.y > + // centroid.y).max(distanceProvider).get(); + // var br2 = polyList.stream().filter(point -> point.x > centroid.x && point.y > + // centroid.y).max(distanceProvider).get(); + + target2020ResultMat.release(); + target2020ResultMat.fromList(List.of(tl, bl, br, tr)); // , tr2, br2, bl2, tl2)); + + return target2020ResultMat; + } catch (NoSuchElementException e) { + return null; + } + } + + /** + * Find the target using the outermost tape corners and a dual target. + * + * @param target the target. + * @return The four outermost tape corners. + */ + private MatOfPoint2f findDualTargetCornerMinAreaRect(StandardCVPipeline.TrackedTarget target) { + if (target.leftRightRotatedRect == null) return null; + + var centroid = target.minAreaRect.center; + Comparator distanceProvider = + Comparator.comparingDouble( + (Point point) -> + FastMath.sqrt( + FastMath.pow(centroid.x - point.x, 2) + FastMath.pow(centroid.y - point.y, 2))); + + var left = target.leftRightRotatedRect.getLeft(); + var right = target.leftRightRotatedRect.getRight(); + + // flip if the "left" target is to the right + if (left.center.x > right.center.x) { + var temp = left; + left = right; + right = temp; + } + + var leftPoints = new Point[4]; + left.points(leftPoints); + var rightPoints = new Point[4]; + right.points(rightPoints); + ArrayList combinedList = new ArrayList<>(List.of(leftPoints)); + combinedList.addAll(List.of(rightPoints)); + + // start looking in the top left quadrant + var tl = + combinedList.stream() + .filter(point -> point.x < centroid.x && point.y < centroid.y) + .max(distanceProvider) + .get(); + var tr = + combinedList.stream() + .filter(point -> point.x > centroid.x && point.y < centroid.y) + .max(distanceProvider) + .get(); + var bl = + combinedList.stream() + .filter(point -> point.x < centroid.x && point.y > centroid.y) + .max(distanceProvider) + .get(); + var br = + combinedList.stream() + .filter(point -> point.x > centroid.x && point.y > centroid.y) + .max(distanceProvider) + .get(); + + boundingBoxResultMat.release(); + boundingBoxResultMat.fromList(List.of(tl, bl, br, tr)); + + return boundingBoxResultMat; + } + + /** + * @param target the target to find the corners of. + * @return the corners. left top, left bottom, right bottom, right top + */ + private MatOfPoint2f findBoundingBoxCorners(StandardCVPipeline.TrackedTarget target) { + // extract the corners + var points = new Point[4]; + target.minAreaRect.points(points); + + // find the tl/tr/bl/br corners + // first, min by left/right + var list_ = Arrays.asList(points); + list_.sort(leftRightComparator); + // of this, we now have left and right + // sort to get top and bottom + var left = new ArrayList<>(List.of(list_.get(0), list_.get(1))); + left.sort(verticalComparator); + 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); + + boundingBoxResultMat.release(); + boundingBoxResultMat.fromList(List.of(tl, bl, br, tr)); + + return boundingBoxResultMat; + } + + MatOfPoint2f goodFeatureToTrackRetval = new MatOfPoint2f(); + + private MatOfPoint2f refineCornersByBestTrack( + MatOfPoint2f corners, Mat greyImg, StandardCVPipeline.TrackedTarget target) { + + MatOfPoint approxf1 = new MatOfPoint(); + var origCornerList = new ArrayList<>(corners.toList()); + approxf1.fromList( + origCornerList.stream() + .map(it -> new Point(it.x - target.boundingRect.x, it.y - target.boundingRect.y)) + .collect(Collectors.toList())); + var croppedImage = greyImg.submat(target.boundingRect); + + Imgproc.goodFeaturesToTrack(croppedImage, approxf1, 0, 0.1, 5); + + // at this point corners is still unmodified so let's map it + List tempList = new ArrayList<>(); + + // shift all points back into global pose + var reshiftedList = + approxf1.toList().stream() + .map(it -> new Point(it.x + target.boundingRect.x, it.y + target.boundingRect.y)) + .collect(Collectors.toList()); + for (Point p : origCornerList) { + // find the goodFeaturesToTrack corner closest to me + var closestPoint = + reshiftedList.stream().min(Comparator.comparingDouble(p_ -> distanceBetween(p_, p))); + if (closestPoint.isEmpty()) { + tempList.add(p); + reshiftedList.remove(p); + } else { + tempList.add(closestPoint.get()); + reshiftedList.remove(closestPoint.get()); + } + } + + goodFeatureToTrackRetval.fromList(tempList); + return goodFeatureToTrackRetval; + } + + // Set the needed parameters to find the refined corners + Size winSize = new Size(4, 4); + Size zeroZone = new Size(-1, -1); // we don't need a zero zone + TermCriteria criteria = new TermCriteria(TermCriteria.EPS + TermCriteria.COUNT, 90, 0.001); + + private boolean shouldRefineCorners = true; + + /** + * Refine an estimated corner position using the cornerSubPixel algorithm. + * + *

TODO should this be here or before the points are chosen? + * + * @param corners the corners detected -- this mat is modified! + * @param greyImg the image taken by the camera as color + * @return the updated mat, same as the corner mat passed in. + */ + private MatOfPoint2f refineCornerEstimateSubPix(MatOfPoint2f corners, Mat greyImg) { + if (!shouldRefineCorners) return corners; // just return + Imgproc.cornerSubPix(greyImg, corners, winSize, zeroZone, criteria); + + return corners; + } + + // NetworkTableEntry tvecE = NetworkTableInstance.getDefault().getTable("SmartDashboard") + // .getEntry("tvec"); + // NetworkTableEntry rvecE = NetworkTableInstance.getDefault().getTable("SmartDashboard") + // .getEntry("rvec"); + + /** + * Calculate the pose of the vision target + * + * @param imageCornerPoints the corners we found. + * @param target the target to process, mutated. + * @return the target, with the pose2d added to it. + */ + public StandardCVPipeline.TrackedTarget calculatePose( + MatOfPoint2f imageCornerPoints, StandardCVPipeline.TrackedTarget target) { + if (objPointsMat.rows() != imageCornerPoints.rows() + || cameraMatrix.rows() < 2 + || distortionCoefficients.cols() < 4) { + System.err.println("can't do solvePNP with invalid params!"); + return null; + } + + imageCornerPoints.copyTo(target.imageCornerPoints); + + try { + Calib3d.solvePnP( + objPointsMat, imageCornerPoints, cameraMatrix, distortionCoefficients, rVec, tVec); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + + // tvecE.setString(tVec.dump()); + // rvecE.setString(rVec.dump()); + + // Algorithm from team 5190 Green Hope Falcons. Can also be found in Ligerbot's vision + // whitepaper + + // the left/right distance to the target, unchanged by tilt. Inches + var x = tVec.get(0, 0)[0]; + + // Z distance in the flat plane is given by + // Z_field = z cos theta + y sin theta. + // Z is the distance "out" of the camera (straight forward). Inches. + var z = + tVec.get(2, 0)[0] * FastMath.cos(tilt_angle) + tVec.get(1, 0)[0] * FastMath.sin(tilt_angle); + + Calib3d.Rodrigues(rVec, rodriguez); + Core.transpose(rodriguez, rot_inv); // rodrigurz.t() + + scaledTvec = matScale(tVec, -1); + Core.gemm(rot_inv, scaledTvec, 1, kMat, 0, pzero_world); + + var angle2 = FastMath.atan2(pzero_world.get(0, 0)[0], pzero_world.get(2, 0)[0]); + + // target rotation is the rotation of the target relative to straight ahead. this number + // should be unchanged if the robot purely translated left/right. + var targetRotation = -angle2; // radians + + // We want a vector that is X forward and Y left. + // We have a Z_field (out of the camera projected onto the field), and an X left/right. + // so Z_field becomes X, and X becomes Y + + //noinspection SuspiciousNameCombination + var targetLocation = new Translation2d(z, -x).times(25.4 / 1000d / distanceDivisor); + target.cameraRelativePose = new Pose2d(targetLocation, new Rotation2d(targetRotation)); + target.rVector = rVec; + target.tVector = tVec; + + return target; + } + + /** + * 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 + */ + public Mat matScale(Mat src, double factor) { + Mat dst = new Mat(src.rows(), src.cols(), src.type()); + Scalar s = new Scalar(factor); // TODO check if we need to add more elements to this + Core.multiply(src, s, dst); + return dst; + } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/SortContoursPipe.java b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/SortContoursPipe.java index c67f5fca4..c4c7681e8 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/SortContoursPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/vision/pipeline/pipes/SortContoursPipe.java @@ -4,25 +4,34 @@ import com.chameleonvision._2.vision.camera.CaptureStaticProperties; import com.chameleonvision._2.vision.enums.SortMode; import com.chameleonvision._2.vision.pipeline.Pipe; import com.chameleonvision._2.vision.pipeline.impl.StandardCVPipeline; -import org.apache.commons.lang3.tuple.Pair; -import org.apache.commons.math3.util.FastMath; - import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.commons.math3.util.FastMath; -public class SortContoursPipe implements Pipe, List> { +public class SortContoursPipe + implements Pipe< + List, List> { - private final Comparator SortByCentermostComparator = Comparator.comparingDouble(this::calcSquareCenterDistance); + private final Comparator SortByCentermostComparator = + Comparator.comparingDouble(this::calcSquareCenterDistance); - private static final Comparator SortByLargestComparator = (rect1, rect2) -> Double.compare(rect2.minAreaRect.size.area(), rect1.minAreaRect.size.area()); - private static final Comparator SortBySmallestComparator = SortByLargestComparator.reversed(); + private static final Comparator SortByLargestComparator = + (rect1, rect2) -> + Double.compare(rect2.minAreaRect.size.area(), rect1.minAreaRect.size.area()); + private static final Comparator SortBySmallestComparator = + SortByLargestComparator.reversed(); - private static final Comparator SortByHighestComparator = (rect1, rect2) -> Double.compare(rect1.minAreaRect.center.y, rect2.minAreaRect.center.y); - private static final Comparator SortByLowestComparator = SortByHighestComparator.reversed(); + private static final Comparator SortByHighestComparator = + (rect1, rect2) -> Double.compare(rect1.minAreaRect.center.y, rect2.minAreaRect.center.y); + private static final Comparator SortByLowestComparator = + SortByHighestComparator.reversed(); - public static final Comparator SortByLeftmostComparator = Comparator.comparingDouble(target -> target.minAreaRect.center.x); - private static final Comparator SortByRightmostComparator = SortByLeftmostComparator.reversed(); + public static final Comparator SortByLeftmostComparator = + Comparator.comparingDouble(target -> target.minAreaRect.center.x); + private static final Comparator SortByRightmostComparator = + SortByLeftmostComparator.reversed(); private SortMode sort; private CaptureStaticProperties camProps; @@ -43,7 +52,8 @@ public class SortContoursPipe implements Pipe, Long> run(List input) { + public Pair, Long> run( + List input) { long processStartNanos = System.nanoTime(); sortedContours.clear(); @@ -78,8 +88,11 @@ public class SortContoursPipe implements Pipe(sortedContours.subList(0, Math.min(input.size(), maxTargets - 1))); - sortedContours.subList(Math.min(input.size(), maxTargets - 1), sortedContours.size()).forEach(StandardCVPipeline.TrackedTarget::release); + var sublistedContors = + new ArrayList<>(sortedContours.subList(0, Math.min(input.size(), maxTargets - 1))); + sortedContours + .subList(Math.min(input.size(), maxTargets - 1), sortedContours.size()) + .forEach(StandardCVPipeline.TrackedTarget::release); sortedContours.clear(); sortedContours = new ArrayList<>(); @@ -88,6 +101,8 @@ public class SortContoursPipe implements Pipe, List> { private double minPercentOfAvg; diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/web/RequestHandler.java b/chameleon-server/src/main/java/com/chameleonvision/_2/web/RequestHandler.java index 80a5f911d..852f2a9c3 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/web/RequestHandler.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/web/RequestHandler.java @@ -19,8 +19,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import edu.wpi.first.wpilibj.geometry.Rotation2d; import io.javalin.http.Context; import io.javalin.http.UploadedFile; -import org.opencv.core.Point3; - import java.io.File; import java.io.FileOutputStream; import java.io.OutputStream; @@ -30,6 +28,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import org.opencv.core.Point3; public class RequestHandler { @@ -56,7 +55,13 @@ public class RequestHandler { // setting up network config after saving boolean isStatic = ConfigManager.settings.connectionType.equals(NetworkMode.STATIC); - boolean state = NetworkManager.setHostname(ConfigManager.settings.hostname) && NetworkManager.setNetwork(isStatic, ConfigManager.settings.ip, ConfigManager.settings.netmask, ConfigManager.settings.gateway); + boolean state = + NetworkManager.setHostname(ConfigManager.settings.hostname) + && NetworkManager.setNetwork( + isStatic, + ConfigManager.settings.ip, + ConfigManager.settings.netmask, + ConfigManager.settings.gateway); if (state) { ctx.status(200); } else { @@ -77,9 +82,15 @@ public class RequestHandler { int cameraIndex = (Integer) data.getOrDefault("camera", -1); var pipelineIndex = (Integer) data.get("pipeline"); - StandardCVPipelineSettings origPipeline = (StandardCVPipelineSettings) VisionManager.getCurrentUIVisionProcess().pipelineManager.getPipeline(pipelineIndex).settings; + StandardCVPipelineSettings origPipeline = + (StandardCVPipelineSettings) + VisionManager.getCurrentUIVisionProcess() + .pipelineManager + .getPipeline(pipelineIndex) + .settings; String tmp = objectMapper.writeValueAsString(origPipeline); - StandardCVPipelineSettings newPipeline = objectMapper.readValue(tmp, StandardCVPipelineSettings.class); + StandardCVPipelineSettings newPipeline = + objectMapper.readValue(tmp, StandardCVPipelineSettings.class); if (cameraIndex == -1) { // same camera @@ -92,12 +103,17 @@ public class RequestHandler { newPipeline.videoModeIndex = cam.getCamera().getProperties().videoModes.size() - 1; } if (newPipeline.is3D) { - var calibration = cam.getCamera().getCalibration(cam.getCamera().getProperties().getVideoMode(newPipeline.videoModeIndex)); + var calibration = + cam.getCamera() + .getCalibration( + cam.getCamera().getProperties().getVideoMode(newPipeline.videoModeIndex)); if (calibration == null) { newPipeline.is3D = false; } } - VisionManager.getCurrentUIVisionProcess().pipelineManager.duplicatePipeline(newPipeline, cam); + VisionManager.getCurrentUIVisionProcess() + .pipelineManager + .duplicatePipeline(newPipeline, cam); ctx.status(200); } else { ctx.status(500); @@ -108,7 +124,6 @@ public class RequestHandler { } } - public static void onCameraSettings(Context ctx) { ObjectMapper objectMapper = kObjectMapper; try { @@ -152,13 +167,15 @@ public class RequestHandler { } // convert from mm to meters pipeManager.calib3dPipe.setSquareSize(squareSize); - VisionManager.getCurrentUIVisionProcess().pipelineManager.calib3dPipe.settings.videoModeIndex = resolutionIndex; + VisionManager.getCurrentUIVisionProcess().pipelineManager.calib3dPipe.settings.videoModeIndex = + resolutionIndex; VisionManager.getCurrentUIVisionProcess().pipelineManager.setCalibrationMode(true); VisionManager.getCurrentUIVisionProcess().getCamera().setVideoMode(resolutionIndex); } public static void onSnapshot(Context ctx) { - Calibrate3dPipeline calPipe = VisionManager.getCurrentUIVisionProcess().pipelineManager.calib3dPipe; + Calibrate3dPipeline calPipe = + VisionManager.getCurrentUIVisionProcess().pipelineManager.calib3dPipe; calPipe.takeSnapshot(); @@ -213,8 +230,14 @@ public class RequestHandler { pointsList.add(pointToAdd); } System.out.println(pointsList.toString()); - if (VisionManager.getCurrentUIVisionProcess().pipelineManager.getCurrentPipeline().settings instanceof StandardCVPipelineSettings) { - var settings = (StandardCVPipelineSettings) VisionManager.getCurrentUIVisionProcess().pipelineManager.getCurrentPipeline().settings; + if (VisionManager.getCurrentUIVisionProcess().pipelineManager.getCurrentPipeline().settings + instanceof StandardCVPipelineSettings) { + var settings = + (StandardCVPipelineSettings) + VisionManager.getCurrentUIVisionProcess() + .pipelineManager + .getCurrentPipeline() + .settings; settings.targetCornerMat.fromList(pointsList); } } catch (Exception e) { @@ -235,7 +258,10 @@ public class RequestHandler { file.getContent().transferTo(stream); stream.close(); } else { - filePath = Paths.get(new File(Main.class.getProtectionDomain().getCodeSource().getLocation().toURI()).getPath()); // quirk to get the current file directory + filePath = + Paths.get( + new File(Main.class.getProtectionDomain().getCodeSource().getLocation().toURI()) + .getPath()); // quirk to get the current file directory } Helpers.setService(filePath); ctx.status(200); diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/web/Server.java b/chameleon-server/src/main/java/com/chameleonvision/_2/web/Server.java index a888d95e3..47774456b 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/web/Server.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/web/Server.java @@ -9,25 +9,32 @@ public class Server { public static void main(int port) { socketHandler = new SocketHandler(); - Javalin app = Javalin.create(javalinConfig -> { - javalinConfig.showJavalinBanner = false; - javalinConfig.addStaticFiles("web"); - javalinConfig.enableCorsForAllOrigins(); - }); - app.ws("/websocket", ws -> { - ws.onConnect(ctx -> { - socketHandler.onConnect(ctx); - System.out.println("Socket Connected"); - }); - ws.onClose(ctx -> { - socketHandler.onClose(ctx); - System.out.println("Socket Disconnected"); - ConfigManager.saveGeneralSettings(); - }); - ws.onBinaryMessage(ctx -> { - socketHandler.onBinaryMessage(ctx); - }); - }); + Javalin app = + Javalin.create( + javalinConfig -> { + javalinConfig.showJavalinBanner = false; + javalinConfig.addStaticFiles("web"); + javalinConfig.enableCorsForAllOrigins(); + }); + app.ws( + "/websocket", + ws -> { + ws.onConnect( + ctx -> { + socketHandler.onConnect(ctx); + System.out.println("Socket Connected"); + }); + ws.onClose( + ctx -> { + socketHandler.onClose(ctx); + System.out.println("Socket Disconnected"); + ConfigManager.saveGeneralSettings(); + }); + ws.onBinaryMessage( + ctx -> { + socketHandler.onBinaryMessage(ctx); + }); + }); app.post("/api/settings/general", RequestHandler::onGeneralSettings); app.post("/api/settings/camera", RequestHandler::onCameraSettings); app.post("/api/vision/duplicate", RequestHandler::onDuplicatePipeline); diff --git a/chameleon-server/src/main/java/com/chameleonvision/_2/web/SocketHandler.java b/chameleon-server/src/main/java/com/chameleonvision/_2/web/SocketHandler.java index 7361bdb57..4b1b99ef5 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/_2/web/SocketHandler.java +++ b/chameleon-server/src/main/java/com/chameleonvision/_2/web/SocketHandler.java @@ -20,9 +20,6 @@ import io.javalin.websocket.WsBinaryMessageContext; import io.javalin.websocket.WsCloseContext; import io.javalin.websocket.WsConnectContext; import io.javalin.websocket.WsContext; -import org.apache.commons.lang3.ArrayUtils; -import org.msgpack.jackson.dataformat.MessagePackFactory; - import java.lang.reflect.Field; import java.nio.ByteBuffer; import java.util.ArrayList; @@ -30,7 +27,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; - +import org.apache.commons.lang3.ArrayUtils; +import org.msgpack.jackson.dataformat.MessagePackFactory; public class SocketHandler { @@ -55,144 +53,179 @@ public class SocketHandler { @SuppressWarnings("unchecked") void onBinaryMessage(WsBinaryMessageContext context) throws Exception { - Map deserialized = objectMapper.readValue((byte[]) ArrayUtils.toPrimitive(context.data()), - new TypeReference<>() { - }); + Map deserialized = + objectMapper.readValue( + (byte[]) ArrayUtils.toPrimitive(context.data()), new TypeReference<>() {}); for (Map.Entry entry : deserialized.entrySet()) { try { VisionProcess currentProcess = VisionManager.getCurrentUIVisionProcess(); CameraCapture currentCamera = currentProcess.getCamera(); CVPipeline currentPipeline = currentProcess.pipelineManager.getCurrentPipeline(); -// System.out.println("entry.getKey()+entry.getValue()= " + entry.getKey() + entry.getValue()); + // System.out.println("entry.getKey()+entry.getValue()= " + entry.getKey() + + // entry.getValue()); switch (entry.getKey()) { - case "driverMode": { - HashMap data = (HashMap) entry.getValue(); - currentProcess.getDriverModeSettings().exposure = (Integer) data.get("driverExposure"); - currentProcess.getDriverModeSettings().brightness = (Integer) data.get("driverBrightness"); - currentProcess.setDriverMode((Boolean) data.get("isDriver")); + case "driverMode": + { + HashMap data = (HashMap) entry.getValue(); + currentProcess.getDriverModeSettings().exposure = + (Integer) data.get("driverExposure"); + currentProcess.getDriverModeSettings().brightness = + (Integer) data.get("driverBrightness"); + currentProcess.setDriverMode((Boolean) data.get("isDriver")); - VisionManager.saveCurrentCameraDriverMode(); - break; - } - case "changeCameraName": { - currentProcess.setCameraNickname((String) entry.getValue()); - sendFullSettings(); - VisionManager.saveCurrentCameraSettings(); - break; - } - case "changePipelineName": { - currentProcess.pipelineManager.renameCurrentPipeline((String) entry.getValue()); - sendFullSettings(); - VisionManager.saveCurrentCameraPipelines(); - break; - } - case "addNewPipeline": { -// HashMap data = (HashMap) entry.getValue(); - String pipeName = (String) entry.getValue(); - // TODO: add to UI selection for new 2d/3d - currentProcess.pipelineManager.addNewPipeline(pipeName); - sendFullSettings(); - VisionManager.saveCurrentCameraPipelines(); - break; - } - case "command": { - switch ((String) entry.getValue()) { - case "deleteCurrentPipeline": - currentProcess.pipelineManager.deleteCurrentPipeline(); - sendFullSettings(); - VisionManager.saveCurrentCameraPipelines(); - break; - case "save": - ConfigManager.saveGeneralSettings(); - VisionManager.saveAllCameras(); - System.out.println("Saved Settings"); - break; + VisionManager.saveCurrentCameraDriverMode(); + break; } - // used to define all incoming commands - break; - } - case "currentCamera": { - VisionManager.setCurrentProcessByIndex((Integer) entry.getValue()); - sendFullSettings(); - break; - } - case "is3D": { - VisionManager.getCurrentUIVisionProcess().setIs3d((Boolean) entry.getValue()); - break; - } - case "currentPipeline": { - currentProcess.pipelineManager.setCurrentPipeline((Integer) entry.getValue()); - sendFullSettings(); - break; - } - case "isPNPCalibration": { - currentProcess.pipelineManager.setCalibrationMode((Boolean) entry.getValue()); - break; - } - case "takeCalibrationSnapshot": { - currentProcess.pipelineManager.calib3dPipe.takeSnapshot(); - } - default: { - - switch (entry.getKey()) {//Pre field value set - case "rotationMode": {//Create new CaptureStaticProperties with new width and height, reset crosshair calib - ImageRotationMode oldRot = currentPipeline.settings.rotationMode; - ImageRotationMode newRot = ImageRotationMode.class.getEnumConstants()[(Integer) entry.getValue()]; - CaptureStaticProperties prop = currentCamera.getProperties().getStaticProperties(); - int width, height; - if (oldRot.isRotated() != newRot.isRotated()) { - width = prop.mode.height; - height = prop.mode.width; - //Creates new video mode with new width and height to create new CaptureStaticProperties and applies it - currentCamera.getProperties().setStaticProperties(new CaptureStaticProperties( - new VideoMode(prop.mode.pixelFormat, width, height, prop.mode.fps), prop.fov)); - } - prop = currentCamera.getProperties().getStaticProperties(); - currentProcess.cameraStreamer.recalculateDivision(); - if (currentPipeline instanceof StandardCVPipeline) - ((StandardCVPipeline) currentPipeline).settings.point.set(prop.mode.width / 2.0, prop.mode.height / 2.0);//Reset Crosshair in single point calib - break; - } - + case "changeCameraName": + { + currentProcess.setCameraNickname((String) entry.getValue()); + sendFullSettings(); + VisionManager.saveCurrentCameraSettings(); + break; } - - - if (currentProcess.pipelineManager.getDriverMode()) { - setField(currentProcess.pipelineManager.driverModePipeline.settings, entry.getKey(), entry.getValue()); - } else { - setField(currentPipeline.settings, entry.getKey(), entry.getValue()); + case "changePipelineName": + { + currentProcess.pipelineManager.renameCurrentPipeline((String) entry.getValue()); + sendFullSettings(); + VisionManager.saveCurrentCameraPipelines(); + break; } - - //Post field value set - switch (entry.getKey()) { - case "exposure": { - currentCamera.setExposure((Integer) entry.getValue()); - break; - } - case "brightness": { - currentCamera.setBrightness((Integer) entry.getValue()); - break; - } - case "gain": { - currentCamera.setGain((Integer) entry.getValue()); - break; - } - case "videoModeIndex": { - if (currentPipeline instanceof StandardCVPipeline) - ((StandardCVPipeline) currentPipeline).settings.point = new DoubleCouple();//This will reset the calibration - currentCamera.setVideoMode((Integer) entry.getValue()); - currentProcess.cameraStreamer.recalculateDivision(); - break; - } - case "streamDivisor": { - currentProcess.cameraStreamer.setDivisor(StreamDivisor.values()[(Integer) entry.getValue()], true); - break; - } + case "addNewPipeline": + { + // HashMap data = (HashMap) + // entry.getValue(); + String pipeName = (String) entry.getValue(); + // TODO: add to UI selection for new 2d/3d + currentProcess.pipelineManager.addNewPipeline(pipeName); + sendFullSettings(); + VisionManager.saveCurrentCameraPipelines(); + break; } + case "command": + { + switch ((String) entry.getValue()) { + case "deleteCurrentPipeline": + currentProcess.pipelineManager.deleteCurrentPipeline(); + sendFullSettings(); + VisionManager.saveCurrentCameraPipelines(); + break; + case "save": + ConfigManager.saveGeneralSettings(); + VisionManager.saveAllCameras(); + System.out.println("Saved Settings"); + break; + } + // used to define all incoming commands + break; + } + case "currentCamera": + { + VisionManager.setCurrentProcessByIndex((Integer) entry.getValue()); + sendFullSettings(); + break; + } + case "is3D": + { + VisionManager.getCurrentUIVisionProcess().setIs3d((Boolean) entry.getValue()); + break; + } + case "currentPipeline": + { + currentProcess.pipelineManager.setCurrentPipeline((Integer) entry.getValue()); + sendFullSettings(); + break; + } + case "isPNPCalibration": + { + currentProcess.pipelineManager.setCalibrationMode((Boolean) entry.getValue()); + break; + } + case "takeCalibrationSnapshot": + { + currentProcess.pipelineManager.calib3dPipe.takeSnapshot(); + } + default: + { + switch (entry.getKey()) { // Pre field value set + case "rotationMode": + { // Create new CaptureStaticProperties with new width and height, reset crosshair + // calib + ImageRotationMode oldRot = currentPipeline.settings.rotationMode; + ImageRotationMode newRot = + ImageRotationMode.class.getEnumConstants()[(Integer) entry.getValue()]; + CaptureStaticProperties prop = + currentCamera.getProperties().getStaticProperties(); + int width, height; + if (oldRot.isRotated() != newRot.isRotated()) { + width = prop.mode.height; + height = prop.mode.width; + // Creates new video mode with new width and height to create new + // CaptureStaticProperties and applies it + currentCamera + .getProperties() + .setStaticProperties( + new CaptureStaticProperties( + new VideoMode( + prop.mode.pixelFormat, width, height, prop.mode.fps), + prop.fov)); + } + prop = currentCamera.getProperties().getStaticProperties(); + currentProcess.cameraStreamer.recalculateDivision(); + if (currentPipeline instanceof StandardCVPipeline) + ((StandardCVPipeline) currentPipeline) + .settings.point.set( + prop.mode.width / 2.0, + prop.mode.height / 2.0); // Reset Crosshair in single point calib + break; + } + } - VisionManager.saveCurrentCameraPipelines(); - break; - } + if (currentProcess.pipelineManager.getDriverMode()) { + setField( + currentProcess.pipelineManager.driverModePipeline.settings, + entry.getKey(), + entry.getValue()); + } else { + setField(currentPipeline.settings, entry.getKey(), entry.getValue()); + } + + // Post field value set + switch (entry.getKey()) { + case "exposure": + { + currentCamera.setExposure((Integer) entry.getValue()); + break; + } + case "brightness": + { + currentCamera.setBrightness((Integer) entry.getValue()); + break; + } + case "gain": + { + currentCamera.setGain((Integer) entry.getValue()); + break; + } + case "videoModeIndex": + { + if (currentPipeline instanceof StandardCVPipeline) + ((StandardCVPipeline) currentPipeline).settings.point = + new DoubleCouple(); // This will reset the calibration + currentCamera.setVideoMode((Integer) entry.getValue()); + currentProcess.cameraStreamer.recalculateDivision(); + break; + } + case "streamDivisor": + { + currentProcess.cameraStreamer.setDivisor( + StreamDivisor.values()[(Integer) entry.getValue()], true); + break; + } + } + + VisionManager.saveCurrentCameraPipelines(); + break; + } } } catch (Exception e) { System.err.println(e.getMessage()); @@ -206,8 +239,7 @@ public class SocketHandler { Field field = obj.getClass().getField(fieldName); if (field.getType().isEnum()) field.set(obj, field.getType().getEnumConstants()[(Integer) value]); - else - field.set(obj, value); + else field.set(obj, value); } catch (NoSuchFieldException | IllegalAccessException ex) { ex.printStackTrace(); } @@ -233,17 +265,32 @@ public class SocketHandler { } public static void broadcastMessage(Object obj) { - broadcastMessage(obj, null);//Broadcasts the message to every user + broadcastMessage(obj, null); // Broadcasts the message to every user } - private static HashMap getOrdinalPipeline(Class cvClass) throws IllegalAccessException { + private static HashMap getOrdinalPipeline(Class cvClass) + throws IllegalAccessException { HashMap tmp = new HashMap<>(); for (Field field : cvClass.getFields()) { // iterate over every field in CVPipelineSettings try { - if (!field.getType().isEnum()) { // if the field is not an enum, get it based on the current pipeline - tmp.put(field.getName(), field.get(VisionManager.getCurrentUIVisionProcess().pipelineManager.getCurrentPipeline().settings)); + if (!field + .getType() + .isEnum()) { // if the field is not an enum, get it based on the current pipeline + tmp.put( + field.getName(), + field.get( + VisionManager.getCurrentUIVisionProcess() + .pipelineManager + .getCurrentPipeline() + .settings)); } else { - var ordinal = (Enum) field.get(VisionManager.getCurrentUIVisionProcess().pipelineManager.getCurrentPipeline().settings); + var ordinal = + (Enum) + field.get( + VisionManager.getCurrentUIVisionProcess() + .pipelineManager + .getCurrentPipeline() + .settings); tmp.put(field.getName(), ordinal.ordinal()); } } catch (IllegalArgumentException e) { @@ -271,18 +318,21 @@ public class SocketHandler { tmp.put("fov", currentCamera.getProperties().getFOV()); tmp.put("streamDivisor", currentVisionProcess.cameraStreamer.getDivisor().ordinal()); - tmp.put("resolution", currentVisionProcess.getCamera().getProperties().getCurrentVideoModeIndex()); + tmp.put( + "resolution", currentVisionProcess.getCamera().getProperties().getCurrentVideoModeIndex()); tmp.put("tilt", currentVisionProcess.getCamera().getProperties().getTilt().getDegrees()); - List calibrations = currentCamera.getAllCalibrationData().stream() - .map(CameraCalibrationConfig.UICameraCalibrationConfig::new).collect(Collectors.toList()); + List calibrations = + currentCamera.getAllCalibrationData().stream() + .map(CameraCalibrationConfig.UICameraCalibrationConfig::new) + .collect(Collectors.toList()); tmp.put("calibration", calibrations); return tmp; } public static void sendFullSettings() { - //General settings + // General settings Map fullSettings = new HashMap<>(); VisionProcess currentProcess = VisionManager.getCurrentUIVisionProcess(); @@ -296,7 +346,9 @@ public class SocketHandler { fullSettings.put("pipelineList", VisionManager.getCurrentCameraPipelineNicknames()); fullSettings.put("resolutionList", VisionManager.getCurrentCameraResolutionList()); fullSettings.put("port", currentProcess.cameraStreamer.getStreamPort()); - fullSettings.put("currentPipelineIndex", VisionManager.getCurrentUIVisionProcess().pipelineManager.getCurrentPipelineIndex()); + fullSettings.put( + "currentPipelineIndex", + VisionManager.getCurrentUIVisionProcess().pipelineManager.getCurrentPipelineIndex()); fullSettings.put("currentCameraIndex", VisionManager.getCurrentUIVisionProcessIndex()); } catch (IllegalAccessException e) { System.err.println("No camera found!"); diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/calibration/CameraCalibrationCoefficients.java b/chameleon-server/src/main/java/com/chameleonvision/common/calibration/CameraCalibrationCoefficients.java new file mode 100644 index 000000000..29f9e8085 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/calibration/CameraCalibrationCoefficients.java @@ -0,0 +1,46 @@ +package com.chameleonvision.common.calibration; + +import com.chameleonvision.common.vision.opencv.Releasable; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.opencv.core.Mat; +import org.opencv.core.MatOfDouble; +import org.opencv.core.Size; + +public class CameraCalibrationCoefficients implements Releasable { + @JsonProperty("resolution") + public final Size resolution; + + @JsonProperty("cameraIntrinsics") + public final JsonMat cameraIntrinsics; + + @JsonProperty("cameraExtrinsics") + public final JsonMat cameraExtrinsics; + + @JsonCreator + public CameraCalibrationCoefficients( + @JsonProperty("resolution") Size resolution, + @JsonProperty("cameraIntrinsics") JsonMat cameraIntrinsics, + @JsonProperty("cameraExtrinsics") JsonMat cameraExtrinsics) { + this.resolution = resolution; + this.cameraIntrinsics = cameraIntrinsics; + this.cameraExtrinsics = cameraExtrinsics; + } + + @JsonIgnore + public Mat getCameraIntrinsicsMat() { + return cameraIntrinsics.getAsMat(); + } + + @JsonIgnore + public MatOfDouble getCameraExtrinsicsMat() { + return cameraExtrinsics.getAsMatOfDouble(); + } + + @Override + public void release() { + cameraIntrinsics.release(); + cameraExtrinsics.release(); + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/calibration/JsonMat.java b/chameleon-server/src/main/java/com/chameleonvision/common/calibration/JsonMat.java new file mode 100644 index 000000000..5677963ab --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/calibration/JsonMat.java @@ -0,0 +1,92 @@ +package com.chameleonvision.common.calibration; + +import com.chameleonvision.common.vision.opencv.Releasable; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Arrays; +import org.opencv.core.CvType; +import org.opencv.core.Mat; +import org.opencv.core.MatOfDouble; + +public class JsonMat implements Releasable { + public final int rows; + public final int cols; + public final int type; + public final double[] data; + + @JsonIgnore private Mat wrappedMat; + private MatOfDouble wrappedMatOfDouble; + + public JsonMat(int rows, int cols, double[] data) { + this(rows, cols, CvType.CV_64FC1, data); + } + + public JsonMat( + @JsonProperty("rows") int rows, + @JsonProperty("cols") int cols, + @JsonProperty("type") int type, + @JsonProperty("data") double[] data) { + this.rows = rows; + this.cols = cols; + this.type = type; + this.data = data; + } + + private static boolean isCameraMatrixMat(Mat mat) { + return mat.type() == CvType.CV_64FC1 && mat.cols() == 3 && mat.rows() == 3; + } + + private static boolean isDistortionCoeffsMat(Mat mat) { + return mat.type() == CvType.CV_64FC1 && mat.cols() == 5 && mat.rows() == 1; + } + + private static boolean isCalibrationMat(Mat mat) { + return isDistortionCoeffsMat(mat) || isCameraMatrixMat(mat); + } + + @JsonIgnore + public static double[] getDataFromMat(Mat mat) { + if (!isCalibrationMat(mat)) return null; + + double[] data = new double[(int) (mat.total() * mat.elemSize())]; + mat.get(0, 0, data); + + int dataLen = -1; + + if (isCameraMatrixMat(mat)) dataLen = 9; + if (isDistortionCoeffsMat(mat)) dataLen = 5; + + // truncate Mat data to correct number data points. + return Arrays.copyOfRange(data, 0, dataLen); + } + + public static JsonMat fromMat(Mat mat) { + if (!isCalibrationMat(mat)) return null; + return new JsonMat(mat.rows(), mat.cols(), getDataFromMat(mat)); + } + + @JsonIgnore + public Mat getAsMat() { + if (this.type != CvType.CV_64FC1) return null; + + if (wrappedMat == null) { + this.wrappedMat = new Mat(this.rows, this.cols, this.type); + this.wrappedMat.put(0, 0, this.data); + } + return this.wrappedMat; + } + + @JsonIgnore + public MatOfDouble getAsMatOfDouble() { + if (this.wrappedMatOfDouble == null) { + this.wrappedMatOfDouble = new MatOfDouble(); + getAsMat().convertTo(wrappedMatOfDouble, CvType.CV_64F); + } + return this.wrappedMatOfDouble; + } + + @Override + public void release() { + getAsMat().release(); + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/datatransfer/networktables/NetworkTablesManager.java b/chameleon-server/src/main/java/com/chameleonvision/common/datatransfer/networktables/NetworkTablesManager.java index c79dcabaa..e7e79c65b 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/datatransfer/networktables/NetworkTablesManager.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/datatransfer/networktables/NetworkTablesManager.java @@ -6,11 +6,15 @@ import edu.wpi.first.networktables.LogMessage; import edu.wpi.first.networktables.NetworkTable; import edu.wpi.first.networktables.NetworkTableInstance; import java.util.function.Consumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class NetworkTablesManager { private NetworkTablesManager() {} + private static final Logger logger = LoggerFactory.getLogger(NetworkTablesManager.class); + private static final NetworkTableInstance ntInstance = NetworkTableInstance.getDefault(); public static final String kRootTableName = "/chameleon-vision"; @@ -32,10 +36,10 @@ public class NetworkTablesManager { @Override public void accept(LogMessage logMessage) { if (!hasReportedConnectionFailure && logMessage.message.contains("timed out")) { - System.err.println("NT Connection has failed! Will retry in background."); + logger.error("NT Connection has failed! Will retry in background."); hasReportedConnectionFailure = true; } else if (logMessage.message.contains("connected")) { - System.out.println("NT Connected!"); + logger.info("NT Connected!"); hasReportedConnectionFailure = false; ScriptManager.queueEvent(ScriptEventType.kNTConnected); } @@ -48,16 +52,16 @@ public class NetworkTablesManager { public static void setClientMode(String host) { isServer = false; - System.out.println("Starting NT Client"); + logger.info("Starting NT Client"); ntInstance.stopServer(); if (host != null) { ntInstance.startClient(host); } else { ntInstance.startClientTeam(getTeamNumber()); if (ntInstance.isConnected()) { - System.out.println("[NetworkTablesManager] Connected to the robot!"); + logger.info("[NetworkTablesManager] Connected to the robot!"); } else { - System.out.println( + logger.info( "[NetworkTablesManager] Could NOT to the robot! Will retry in the background..."); } } @@ -69,7 +73,7 @@ public class NetworkTablesManager { public static void setServerMode() { isServer = true; - System.out.println("Starting NT Server"); + logger.info("Starting NT Server"); ntInstance.stopClient(); ntInstance.startServer(); } diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/logging/DebugLogger.java b/chameleon-server/src/main/java/com/chameleonvision/common/logging/DebugLogger.java deleted file mode 100644 index b37f982e1..000000000 --- a/chameleon-server/src/main/java/com/chameleonvision/common/logging/DebugLogger.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.chameleonvision.common.logging; - -public class DebugLogger { - - private final boolean verbose; - - public DebugLogger(boolean verbose) { - this.verbose = verbose; - } - - public void printInfo(String infoMessage) { - if (verbose) { - System.out.println(infoMessage); - } - } - - public void printInfo(String smallInfo, String largeInfo) { - System.out.println(verbose ? String.format("%s - %s", smallInfo, largeInfo) : smallInfo); - } -} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/networking/LinuxNetworking.java b/chameleon-server/src/main/java/com/chameleonvision/common/networking/LinuxNetworking.java index 1df9dc0f3..b02e9df16 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/networking/LinuxNetworking.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/networking/LinuxNetworking.java @@ -9,13 +9,18 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import org.apache.commons.io.FileUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class LinuxNetworking extends SysNetworking { private static final String PATH = "/etc/dhcpcd.conf"; + private Logger logger = LoggerFactory.getLogger(LinuxNetworking.class); + @Override public boolean setDHCP() { File dhcpConf = new File(PATH); + logger.debug("Removing static IP from {}", PATH); if (dhcpConf.exists()) { try { List lines = FileUtils.readLines(dhcpConf, StandardCharsets.UTF_8); @@ -44,7 +49,7 @@ public class LinuxNetworking extends SysNetworking { } } else { - System.err.println("dhcpcd5 is not installed, unable to set IP."); + logger.error("dhcpcd5 is not installed, unable to set IP."); return false; } return true; diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/scripting/ScriptEvent.java b/chameleon-server/src/main/java/com/chameleonvision/common/scripting/ScriptEvent.java index e2c545808..4dd6e67b7 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/scripting/ScriptEvent.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/scripting/ScriptEvent.java @@ -1,14 +1,15 @@ package com.chameleonvision.common.scripting; -import com.chameleonvision.common.logging.DebugLogger; import com.chameleonvision.common.util.ShellExec; import java.io.IOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class ScriptEvent { - private static final DebugLogger logger = new DebugLogger(true); private static final ShellExec executor = new ShellExec(true, true); public final ScriptConfig config; + private final Logger logger = LoggerFactory.getLogger(ScriptEvent.class); public ScriptEvent(ScriptConfig config) { this.config = config; @@ -23,12 +24,13 @@ public class ScriptEvent { if (!error.isEmpty()) { System.err.printf("Error when running \"%s\" script: %s\n", config.eventType.name(), error); } else if (!output.isEmpty()) { - logger.printInfo( + logger.info( String.format("Output from \"%s\" script: %s\n", config.eventType.name(), output)); } - logger.printInfo( + logger.info( String.format( - "Script for %s ran with command line: \"%s\", exit code: %d, output: %s, error: %s\n", + "Script for %s ran with command line: \"%s\", exit code: %d, output: %s, " + + "error: %s\n", config.eventType.name(), config.command, retVal, output, error)); return retVal; } diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/scripting/ScriptManager.java b/chameleon-server/src/main/java/com/chameleonvision/common/scripting/ScriptManager.java index 4edf0a883..e3839b1a9 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/scripting/ScriptManager.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/scripting/ScriptManager.java @@ -1,6 +1,5 @@ package com.chameleonvision.common.scripting; -import com.chameleonvision.common.logging.DebugLogger; import com.chameleonvision.common.util.LoopingRunnable; import com.chameleonvision.common.util.Platform; import com.chameleonvision.common.util.file.JacksonUtils; @@ -11,10 +10,12 @@ import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.concurrent.LinkedBlockingDeque; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class ScriptManager { - private static DebugLogger logger = new DebugLogger(true); + private static final Logger logger = LoggerFactory.getLogger(ScriptManager.class); private ScriptManager() {} @@ -124,7 +125,7 @@ public class ScriptManager { if (!Platform.CurrentPlatform.isWindows()) { try { queuedEvents.putLast(eventType); - logger.printInfo("Queued event: " + eventType.name()); + logger.info("Queued event: " + eventType.name()); } catch (InterruptedException e) { System.err.println("Failed to add event to queue: " + eventType.name()); } diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/util/ReflectionUtils.java b/chameleon-server/src/main/java/com/chameleonvision/common/util/ReflectionUtils.java new file mode 100644 index 000000000..ec6590cae --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/util/ReflectionUtils.java @@ -0,0 +1,42 @@ +package com.chameleonvision.common.util; + +public class ReflectionUtils { + + public static StackTraceElement[] getFullStackTrace() { + return Thread.currentThread().getStackTrace(); + } + + public static StackTraceElement getNthCaller(int n) { + if (n < 0) n = 0; + return Thread.currentThread().getStackTrace()[n]; + } + + public static String getCallerClassName() { + StackTraceElement[] stElements = Thread.currentThread().getStackTrace(); + for (int i = 1; i < stElements.length; i++) { + StackTraceElement ste = stElements[i]; + if (!ste.getClassName().equals(ReflectionUtils.class.getName()) + && ste.getClassName().indexOf("java.lang.Thread") != 0) { + return ste.getClassName(); + } + } + return null; + } + + public static String getCallerCallerClassName() { + StackTraceElement[] stElements = Thread.currentThread().getStackTrace(); + String callerClassName = null; + for (int i = 1; i < stElements.length; i++) { + StackTraceElement ste = stElements[i]; + if (!ste.getClassName().equals(ReflectionUtils.class.getName()) + && ste.getClassName().indexOf("java.lang.Thread") != 0) { + if (callerClassName == null) { + callerClassName = ste.getClassName(); + } else if (!callerClassName.equals(ste.getClassName())) { + return ste.getClassName(); + } + } + } + return null; + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/util/TestUtils.java b/chameleon-server/src/main/java/com/chameleonvision/common/util/TestUtils.java new file mode 100644 index 000000000..5c5e4db4f --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/util/TestUtils.java @@ -0,0 +1,123 @@ +package com.chameleonvision.common.util; + +import edu.wpi.cscore.CameraServerCvJNI; +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Optional; +import org.opencv.core.Mat; +import org.opencv.highgui.HighGui; + +public class TestUtils { + + public enum WPI2019Image { + kCargoAngledDark48in(1.2192), + kCargoSideStraightDark36in(0.9144), + kCargoSideStraightDark60in(1.524), + kCargoSideStraightDark72in(1.8288), + kCargoSideStraightPanelDark36in(0.9144), + kCargoStraightDark19in(0.4826), + kCargoStraightDark24in(0.6096), + kCargoStraightDark48in(1.2192), + kCargoStraightDark72in(1.8288), + kCargoStraightDark72in_HighRes(1.8288), + kCargoStraightDark90in(2.286); + + public static double FOV = 68.5; + + public final double distanceMeters; + public final String path; + + String getPath() { + var filename = this.toString().substring(1); + return "\\2019\\WPI\\" + filename + ".jpg"; + } + + WPI2019Image(double distanceMeters) { + this.distanceMeters = distanceMeters; + this.path = getPath(); + } + } + + public enum WPI2020Image { + kBlueGoal_060in_Center(1.524), + kBlueGoal_084in_Center(2.1336), + kBlueGoal_108in_Center(2.7432), + kBlueGoal_132in_Center(3.3528), + kBlueGoal_156in_Center(3.9624), + kBlueGoal_180in_Center(4.572), + kBlueGoal_156in_Left(3.9624), + kBlueGoal_224in_Left(5.6896), + kBlueGoal_228in_ProtectedZone(5.7912), + kBlueGoal_330in_ProtectedZone(8.382), + kBlueGoal_Far_ProtectedZone(10.668), // TODO: find a more accurate distance + kRedLoading_016in_Down(0.4064), + kRedLoading_030in_Down(0.762), + kRedLoading_048in_Down(1.2192), + kRedLoading_048in(1.2192), + kRedLoading_060in(1.524), + kRedLoading_084in(2.1336), + kRedLoading_108in(2.7432); + + public static double FOV = 68.5; + + public final double distanceMeters; + public final String path; + + String getPath() { + var filename = this.toString().substring(1).replace('_', '-'); + return "\\2020\\WPI\\" + filename + ".jpg"; + } + + WPI2020Image(double distanceMeters) { + this.distanceMeters = distanceMeters; + this.path = getPath(); + } + } + + private static Path getTestImagesPath() { + var folder = TestUtils.class.getClassLoader().getResource("testimages"); + return Optional.ofNullable(folder).map(url -> new File(url.getFile()).toPath()).orElse(null); + } + + public static Path getCalibrationPath() { + var folder = TestUtils.class.getClassLoader().getResource("calibration"); + return Optional.ofNullable(folder).map(url -> new File(url.getFile()).toPath()).orElse(null); + } + + public static Path getWPIImagePath(WPI2020Image image) { + return Path.of(getTestImagesPath().toString(), image.path); + } + + public static Path getWPIImagePath(WPI2019Image image) { + return Path.of(getTestImagesPath().toString(), image.path); + } + + public static void loadLibraries() { + try { + CameraServerCvJNI.forceLoad(); + } catch (IOException e) { + // ignored + } + } + + private static int DefaultTimeoutMillis = 5000; + + public static void showImage(Mat frame, String title, int timeoutMs) { + HighGui.imshow(title, frame); + HighGui.waitKey(timeoutMs); + HighGui.destroyAllWindows(); + } + + public static void showImage(Mat frame, int timeoutMs) { + showImage(frame, "", timeoutMs); + } + + public static void showImage(Mat frame, String title) { + showImage(frame, title, DefaultTimeoutMillis); + } + + public static void showImage(Mat frame) { + showImage(frame, DefaultTimeoutMillis); + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/util/file/FileUtils.java b/chameleon-server/src/main/java/com/chameleonvision/common/util/file/FileUtils.java index b02366622..9c838133b 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/util/file/FileUtils.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/util/file/FileUtils.java @@ -1,6 +1,5 @@ package com.chameleonvision.common.util.file; -import com.chameleonvision.common.logging.DebugLogger; import com.chameleonvision.common.util.Platform; import java.io.File; import java.io.IOException; @@ -11,9 +10,14 @@ import java.nio.file.attribute.PosixFilePermission; import java.util.Arrays; import java.util.HashSet; import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class FileUtils { - private static DebugLogger logger = new DebugLogger(true); + + private FileUtils() {} + + private static Logger logger = LoggerFactory.getLogger(FileUtils.class); private static final Set allReadWriteExecutePerms = new HashSet<>(Arrays.asList(PosixFilePermission.values())); @@ -23,7 +27,7 @@ public class FileUtils { Set perms = Files.readAttributes(path, PosixFileAttributes.class).permissions(); if (!perms.equals(allReadWriteExecutePerms)) { - logger.printInfo("Setting perms on" + path.toString()); + logger.info("Setting perms on" + path.toString()); Files.setPosixFilePermissions(path, perms); if (thisFile.isDirectory()) { for (File subfile : thisFile.listFiles()) { @@ -46,8 +50,7 @@ public class FileUtils { } } else { // TODO file perms on Windows - System.out.println( - "File permission setting not available on Windows. Not changing file permissions."); + logger.info("Cannot set directory permissions on Windows!"); } } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/util/math/MathUtils.java b/chameleon-server/src/main/java/com/chameleonvision/common/util/math/MathUtils.java index f08fa672f..61337cf45 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/util/math/MathUtils.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/util/math/MathUtils.java @@ -26,4 +26,8 @@ public class MathUtils { double toMult = Math.pow(10, to); return (double) Math.round(value * toMult) / toMult; } + + public static double nanosToMillis(long nanos) { + return nanos / 1000000.0; + } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/util/numbers/DoubleCouple.java b/chameleon-server/src/main/java/com/chameleonvision/common/util/numbers/DoubleCouple.java index a69d6e439..bf1ceea87 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/util/numbers/DoubleCouple.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/util/numbers/DoubleCouple.java @@ -1,5 +1,7 @@ package com.chameleonvision.common.util.numbers; +import org.opencv.core.Point; + public class DoubleCouple extends NumberCouple { public DoubleCouple() { @@ -9,4 +11,17 @@ public class DoubleCouple extends NumberCouple { public DoubleCouple(Double first, Double second) { super(first, second); } + + public DoubleCouple(Point point) { + super(point.x, point.y); + } + + public Point toPoint() { + return new Point(first, second); + } + + public void fromPoint(Point point) { + first = point.x; + second = point.y; + } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/util/numbers/NumberCouple.java b/chameleon-server/src/main/java/com/chameleonvision/common/util/numbers/NumberCouple.java index 8535a713e..bc5c0b14c 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/util/numbers/NumberCouple.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/util/numbers/NumberCouple.java @@ -2,8 +2,8 @@ package com.chameleonvision.common.util.numbers; public abstract class NumberCouple { - private T first; - private T second; + protected T first; + protected T second; public NumberCouple(T first, T second) { this.first = first; diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/camera/CaptureStaticProperties.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/camera/CaptureStaticProperties.java deleted file mode 100644 index f2dee217e..000000000 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/camera/CaptureStaticProperties.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.chameleonvision.common.vision.camera; - -import edu.wpi.cscore.VideoMode; -import org.apache.commons.math3.fraction.Fraction; -import org.apache.commons.math3.util.FastMath; -import org.opencv.core.Point; - -public class CaptureStaticProperties { - public final int imageWidth; - public final int imageHeight; - public final double fov; - public final double imageArea; - public final double centerX; - public final double centerY; - public final Point centerPoint; - public final double horizontalFocalLength; - public final double verticalFocalLength; - public final VideoMode mode; - - public CaptureStaticProperties(VideoMode mode, double fov) { - this.mode = mode; - - this.imageWidth = mode.width; - this.imageHeight = mode.height; - this.fov = fov; - - imageArea = imageHeight * imageWidth; - centerX = imageWidth / 2.0 - 0.5; - centerY = imageHeight / 2.0 - 0.5; - centerPoint = new Point(centerX, centerY); - - // Calculations from pinhole-model. - double diagonalView = FastMath.toRadians(this.fov); - Fraction aspectRatio = new Fraction(imageWidth, imageHeight); - - int horizontalRatio = aspectRatio.getNumerator(); - int verticalRatio = aspectRatio.getDenominator(); - - double diagonalAspect = FastMath.hypot(horizontalRatio, verticalRatio); - - double horizontalView = - FastMath.atan(FastMath.tan(diagonalView / 2) * (horizontalRatio / diagonalAspect)) * 2; - double verticalView = - FastMath.atan(FastMath.tan(diagonalView / 2) * (verticalRatio / diagonalAspect)) * 2; - - horizontalFocalLength = imageWidth / (2 * FastMath.tan(horizontalView / 2)); - verticalFocalLength = imageHeight / (2 * FastMath.tan(verticalView / 2)); - } -} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/Frame.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/Frame.java index e932572f9..1176b7c06 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/Frame.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/Frame.java @@ -1,18 +1,37 @@ package com.chameleonvision.common.vision.frame; +import com.chameleonvision.common.vision.opencv.CVMat; +import com.chameleonvision.common.vision.opencv.Releasable; import org.opencv.core.Mat; -public class Frame { - public long timestampNanos; - public Mat image; +public class Frame implements Releasable { + public final long timestampNanos; + public final CVMat image; + public final FrameStaticProperties frameStaticProperties; - public Frame(Mat image) { - this.image = image; - timestampNanos = System.nanoTime(); - } - - public Frame(Mat image, long timestampNanos) { + public Frame(CVMat image, long timestampNanos, FrameStaticProperties frameStaticProperties) { this.image = image; this.timestampNanos = timestampNanos; + this.frameStaticProperties = frameStaticProperties; + } + + public Frame(CVMat image, FrameStaticProperties frameStaticProperties) { + this(image, System.nanoTime(), frameStaticProperties); + } + + public void copyTo(Mat destMat) { + image.getMat().copyTo(destMat); + } + + public static Frame copyFrom(Frame frame) { + Mat newMat = new Mat(); + frame.image.getMat().copyTo(newMat); + frame.release(); + return new Frame(new CVMat(newMat), frame.timestampNanos, frame.frameStaticProperties); + } + + @Override + public void release() { + image.release(); } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/FrameDivisor.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/FrameDivisor.java new file mode 100644 index 000000000..ddadf951b --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/FrameDivisor.java @@ -0,0 +1,14 @@ +package com.chameleonvision.common.vision.frame; + +public enum FrameDivisor { + NONE(1), + HALF(2), + QUARTER(4), + SIXTH(6); + + public final Integer value; + + FrameDivisor(int value) { + this.value = value; + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/FrameProvider.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/FrameProvider.java index 1827df9f7..37cd24078 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/FrameProvider.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/FrameProvider.java @@ -2,6 +2,4 @@ package com.chameleonvision.common.vision.frame; public interface FrameProvider { Frame getFrame(); - - FrameStaticProperties getFrameProperties(); } diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/FrameStaticProperties.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/FrameStaticProperties.java index 640acfbcb..9408ba5b4 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/FrameStaticProperties.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/FrameStaticProperties.java @@ -3,6 +3,7 @@ package com.chameleonvision.common.vision.frame; import edu.wpi.cscore.VideoMode; import org.apache.commons.math3.fraction.Fraction; import org.apache.commons.math3.util.FastMath; +import org.opencv.core.Point; /** Represents the properties of a frame. */ public class FrameStaticProperties { @@ -12,6 +13,7 @@ public class FrameStaticProperties { public final double imageArea; public final double centerX; public final double centerY; + public final Point centerPoint; public final double horizontalFocalLength; public final double verticalFocalLength; @@ -41,6 +43,7 @@ public class FrameStaticProperties { centerX = ((double) this.imageWidth / 2) - 0.5; centerY = ((double) this.imageHeight / 2) - 0.5; + centerPoint = new Point(centerX, centerY); // pinhole model calculations double diagonalView = FastMath.toRadians(this.fov); diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/consumer/DummyFrameConsumer.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/consumer/DummyFrameConsumer.java new file mode 100644 index 000000000..3ea069317 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/consumer/DummyFrameConsumer.java @@ -0,0 +1,11 @@ +package com.chameleonvision.common.vision.frame.consumer; + +import com.chameleonvision.common.vision.frame.Frame; +import com.chameleonvision.common.vision.frame.FrameConsumer; + +public class DummyFrameConsumer implements FrameConsumer { + @Override + public void consume(Frame frame) { + frame.release(); // lol ez + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/provider/FileFrameProvider.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/provider/FileFrameProvider.java index 4e39704e2..f923e1e01 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/provider/FileFrameProvider.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/provider/FileFrameProvider.java @@ -3,6 +3,7 @@ package com.chameleonvision.common.vision.frame.provider; import com.chameleonvision.common.vision.frame.Frame; import com.chameleonvision.common.vision.frame.FrameProvider; import com.chameleonvision.common.vision.frame.FrameStaticProperties; +import com.chameleonvision.common.vision.opencv.CVMat; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -51,7 +52,7 @@ public class FileFrameProvider implements FrameProvider { if (image.cols() > 0 && image.rows() > 0) { m_properties = new FrameStaticProperties(image.width(), image.height(), m_fov); - m_frame = new Frame(image); + m_frame = new Frame(new CVMat(image), m_properties); } else { throw new RuntimeException("Image loading failed!"); } @@ -76,11 +77,6 @@ public class FileFrameProvider implements FrameProvider { return m_reloadImage; } - @Override - public FrameStaticProperties getFrameProperties() { - return m_properties; - } - @Override public Frame getFrame() { if (m_reloadImage) { diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/provider/NetworkFrameProvider.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/provider/NetworkFrameProvider.java index b111bde37..ad63a283c 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/provider/NetworkFrameProvider.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/provider/NetworkFrameProvider.java @@ -2,7 +2,6 @@ package com.chameleonvision.common.vision.frame.provider; import com.chameleonvision.common.vision.frame.Frame; import com.chameleonvision.common.vision.frame.FrameProvider; -import com.chameleonvision.common.vision.frame.FrameStaticProperties; import org.apache.commons.lang3.NotImplementedException; public class NetworkFrameProvider implements FrameProvider { @@ -10,9 +9,4 @@ public class NetworkFrameProvider implements FrameProvider { public Frame getFrame() { throw new NotImplementedException(""); } - - @Override - public FrameStaticProperties getFrameProperties() { - throw new NotImplementedException(""); - } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/provider/USBFrameProvider.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/provider/USBFrameProvider.java index f5bdbed62..e942429d4 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/provider/USBFrameProvider.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/frame/provider/USBFrameProvider.java @@ -2,7 +2,6 @@ package com.chameleonvision.common.vision.frame.provider; import com.chameleonvision.common.vision.frame.Frame; import com.chameleonvision.common.vision.frame.FrameProvider; -import com.chameleonvision.common.vision.frame.FrameStaticProperties; import org.apache.commons.lang3.NotImplementedException; public class USBFrameProvider implements FrameProvider { @@ -10,9 +9,4 @@ public class USBFrameProvider implements FrameProvider { public Frame getFrame() { throw new NotImplementedException(""); } - - @Override - public FrameStaticProperties getFrameProperties() { - throw new NotImplementedException(""); - } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/CVMat.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/CVMat.java new file mode 100644 index 000000000..116e3b97c --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/CVMat.java @@ -0,0 +1,44 @@ +package com.chameleonvision.common.vision.opencv; + +import com.chameleonvision.common.util.ReflectionUtils; +import java.util.HashSet; +import org.opencv.core.Mat; + +public class CVMat implements Releasable { + private static final HashSet allMats = new HashSet<>(); + + private final Mat mat; + + public CVMat() { + this.mat = new Mat(); + } + + public void copyTo(CVMat srcMat) { + copyTo(srcMat.getMat()); + } + + public void copyTo(Mat srcMat) { + srcMat.copyTo(mat); + } + + public CVMat(Mat mat) { + this.mat = mat; + if (allMats.add(mat)) { + System.out.println("(CVMat) Added new Mat from: \n" + ReflectionUtils.getNthCaller(3)); + } + } + + @Override + public void release() { + allMats.remove(mat); + mat.release(); + } + + public Mat getMat() { + return mat; + } + + public static int getMatCount() { + return allMats.size(); + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/CVShape.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/CVShape.java new file mode 100644 index 000000000..1e01dc0a8 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/CVShape.java @@ -0,0 +1,60 @@ +package com.chameleonvision.common.vision.opencv; + +import org.opencv.core.MatOfPoint2f; +import org.opencv.core.MatOfPoint3f; +import org.opencv.imgproc.Imgproc; + +public class CVShape { + public final Contour contour; + public final ContourShape shape; + + private MatOfPoint3f customTarget = null; + + private MatOfPoint2f approxCurve = new MatOfPoint2f(); + + public CVShape(Contour contour, ContourShape shape) { + this.contour = contour; + this.shape = shape; + } + + public CVShape(Contour contour, MatOfPoint3f targetPoints) { + this.contour = contour; + this.shape = ContourShape.Custom; + customTarget = targetPoints; + } + + public MatOfPoint2f getApproxPolyDp(double epsilon, boolean closed) { + approxCurve.release(); + approxCurve = new MatOfPoint2f(); + + Imgproc.approxPolyDP(contour.getMat2f(), approxCurve, epsilon, closed); + return approxCurve; + } + + public MatOfPoint2f getApproxPolyDpConvex(double epsilon, boolean closed) { + approxCurve.release(); + approxCurve = new MatOfPoint2f(); + + Imgproc.approxPolyDP(contour.getConvexHull(), approxCurve, epsilon, closed); + return approxCurve; + } + + boolean approxPolyMatchesShape() { + var pointList = approxCurve.toList(); + + // TODO: @Matt + switch (shape) { + case Custom: + break; + case Circle: + break; + case Triangle: + break; + case Square: + break; + case Rectangle: + break; + } + return true; + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/Contour.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/Contour.java index e977114d7..740189530 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/Contour.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/Contour.java @@ -1,14 +1,18 @@ package com.chameleonvision.common.vision.opencv; import com.chameleonvision.common.util.math.MathUtils; -import java.util.ArrayList; import java.util.Comparator; -import java.util.List; -import org.opencv.core.*; +import org.opencv.core.CvType; +import org.opencv.core.MatOfInt; +import org.opencv.core.MatOfPoint; +import org.opencv.core.MatOfPoint2f; +import org.opencv.core.Point; +import org.opencv.core.Rect; +import org.opencv.core.RotatedRect; import org.opencv.imgproc.Imgproc; import org.opencv.imgproc.Moments; -public class Contour { +public class Contour implements Releasable { public static final Comparator SortByMomentsX = Comparator.comparingDouble( @@ -17,14 +21,36 @@ public class Contour { public final MatOfPoint mat; private Double area = Double.NaN; + private Double perimeter = Double.NaN; + private MatOfPoint2f mat2f = null; private RotatedRect minAreaRect = null; private Rect boundingRect = null; private Moments moments = null; + private MatOfPoint2f convexHull = null; + public Contour(MatOfPoint mat) { this.mat = mat; } + public MatOfPoint2f getMat2f() { + if (mat2f == null) { + mat2f = new MatOfPoint2f(mat.toArray()); + mat.convertTo(mat2f, CvType.CV_32F); + } + return mat2f; + } + + public MatOfPoint2f getConvexHull() { + if (this.convexHull == null) { + var ints = new MatOfInt(); + Imgproc.convexHull(mat, ints); + this.convexHull = Contour.convertIndexesToPoints(mat, ints); + ints.release(); + } + return convexHull; + } + public double getArea() { if (Double.isNaN(area)) { area = Imgproc.contourArea(mat); @@ -32,11 +58,16 @@ public class Contour { return area; } + public double getPerimeter() { + if (Double.isNaN(perimeter)) { + perimeter = Imgproc.arcLength(getMat2f(), true); + } + return perimeter; + } + public RotatedRect getMinAreaRect() { if (minAreaRect == null) { - MatOfPoint2f temp = new MatOfPoint2f(mat.toArray()); - minAreaRect = Imgproc.minAreaRect(temp); - temp.release(); + minAreaRect = Imgproc.minAreaRect(getMat2f()); } return minAreaRect; } @@ -60,20 +91,23 @@ public class Contour { } public boolean isEmpty() { - return mat.cols() != 0 && mat.rows() != 0; + return mat.empty(); } - public boolean isIntersecting(Contour secondContour, ContourIntersection intersection) { + public boolean isIntersecting( + Contour secondContour, ContourIntersectionDirection intersectionDirection) { boolean isIntersecting = false; - if (intersection == ContourIntersection.None) { + if (intersectionDirection == ContourIntersectionDirection.None) { isIntersecting = true; } else { try { MatOfPoint2f intersectMatA = new MatOfPoint2f(); MatOfPoint2f intersectMatB = new MatOfPoint2f(); - intersectMatA.fromArray(mat.toArray()); - intersectMatB.fromArray(secondContour.mat.toArray()); + + mat.convertTo(intersectMatA, CvType.CV_32F); + secondContour.mat.convertTo(intersectMatB, CvType.CV_32F); + RotatedRect a = Imgproc.fitEllipse(intersectMatA); RotatedRect b = Imgproc.fitEllipse(intersectMatB); double mA = MathUtils.toSlope(a.angle); @@ -86,7 +120,7 @@ public class Contour { double intersectionY = (mA * (intersectionX - x0A)) + y0A; double massX = (x0A + x0B) / 2; double massY = (y0A + y0B) / 2; - switch (intersection) { + switch (intersectionDirection) { case Up: if (intersectionY < massY) isIntersecting = true; break; @@ -112,47 +146,53 @@ public class Contour { // TODO: refactor to do "infinite" contours public static Contour groupContoursByIntersection( - Contour firstContour, Contour secondContour, ContourIntersection intersection) { - if (firstContour.isIntersecting(secondContour, intersection)) { + Contour firstContour, Contour secondContour, ContourIntersectionDirection intersection) { + if (areIntersecting(firstContour, secondContour, intersection)) { return combineContours(firstContour, secondContour); } else { return null; } } - // TODO: does this leak? + public static boolean areIntersecting( + Contour firstContour, + Contour secondContour, + ContourIntersectionDirection intersectionDirection) { + return firstContour.isIntersecting(secondContour, intersectionDirection) + || secondContour.isIntersecting(firstContour, intersectionDirection); + } + private static Contour combineContours(Contour... contours) { - List fullContourPoints = new ArrayList<>(); + var points = new MatOfPoint(); for (var contour : contours) { - fullContourPoints.addAll(contour.mat.toList()); + points.push_back(contour.mat); } - var points = new MatOfPoint(fullContourPoints.toArray(new Point[0])); var finalContour = new Contour(points); - if (!finalContour.isEmpty()) { - return finalContour; - } else return null; + boolean contourEmpty = finalContour.isEmpty(); + return contourEmpty ? null : finalContour; } - // TODO: move these? also docs plox - public enum ContourIntersection { - None, - Up, - Down, - Left, - Right + @Override + public void release() { + mat.release(); + mat2f.release(); + convexHull.release(); } - public enum ContourGrouping { - Single(1), - Dual(2); + public static MatOfPoint2f convertIndexesToPoints(MatOfPoint contour, MatOfInt indexes) { + int[] arrIndex = indexes.toArray(); + Point[] arrContour = contour.toArray(); + Point[] arrPoints = new Point[arrIndex.length]; - public final int count; - - ContourGrouping(int count) { - this.count = count; + for (int i = 0; i < arrIndex.length; i++) { + arrPoints[i] = arrContour[arrIndex[i]]; } + + var hull = new MatOfPoint2f(); + hull.fromArray(arrPoints); + return hull; } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/ContourGroupingMode.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/ContourGroupingMode.java new file mode 100644 index 000000000..dfed75685 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/ContourGroupingMode.java @@ -0,0 +1,12 @@ +package com.chameleonvision.common.vision.opencv; + +public enum ContourGroupingMode { + Single(1), + Dual(2); + + public final int count; + + ContourGroupingMode(int count) { + this.count = count; + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/ContourIntersectionDirection.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/ContourIntersectionDirection.java new file mode 100644 index 000000000..e14ff1ae2 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/ContourIntersectionDirection.java @@ -0,0 +1,9 @@ +package com.chameleonvision.common.vision.opencv; + +public enum ContourIntersectionDirection { + None, + Up, + Down, + Left, + Right +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/ContourShape.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/ContourShape.java new file mode 100644 index 000000000..dc036c427 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/ContourShape.java @@ -0,0 +1,15 @@ +package com.chameleonvision.common.vision.opencv; + +public enum ContourShape { + Custom(-1), + Circle(0), + Triangle(3), + Square(4), + Rectangle(4); + + public final int sides; + + ContourShape(int sides) { + this.sides = sides; + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/ContourSortMode.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/ContourSortMode.java new file mode 100644 index 000000000..d530528e7 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/ContourSortMode.java @@ -0,0 +1,29 @@ +package com.chameleonvision.common.vision.opencv; + +import com.chameleonvision.common.vision.target.PotentialTarget; +import java.util.Comparator; +import org.apache.commons.math3.util.FastMath; + +public enum ContourSortMode { + Largest(Comparator.comparingDouble(PotentialTarget::getArea)), + Smallest(Largest.getComparator().reversed()), + Highest(Comparator.comparingDouble(rect -> rect.getMinAreaRect().center.y)), + Lowest(Highest.getComparator().reversed()), + Leftmost(Comparator.comparingDouble(target -> target.getMinAreaRect().center.x)), + Rightmost(Leftmost.getComparator().reversed()), + Centermost( + Comparator.comparingDouble( + rect -> + (FastMath.pow(rect.getMinAreaRect().center.y, 2) + + FastMath.pow(rect.getMinAreaRect().center.x, 2)))); + + private Comparator m_comparator; + + ContourSortMode(Comparator comparator) { + m_comparator = comparator; + } + + public Comparator getComparator() { + return m_comparator; + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/DualMat.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/DualMat.java new file mode 100644 index 000000000..9b65219dc --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/DualMat.java @@ -0,0 +1,8 @@ +package com.chameleonvision.common.vision.opencv; + +import org.opencv.core.Mat; + +public class DualMat { + public Mat first; + public Mat second; +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/Releasable.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/Releasable.java new file mode 100644 index 000000000..1f5c08db5 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/opencv/Releasable.java @@ -0,0 +1,5 @@ +package com.chameleonvision.common.vision.opencv; + +public interface Releasable { + void release(); +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/CVPipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/CVPipe.java similarity index 78% rename from chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/CVPipe.java rename to chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/CVPipe.java index 72f391bc3..31d0d99c9 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/CVPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/CVPipe.java @@ -1,4 +1,4 @@ -package com.chameleonvision.common.vision.pipeline; +package com.chameleonvision.common.vision.pipe; import java.util.function.Function; @@ -10,9 +10,9 @@ import java.util.function.Function; * @param Output type for the pipe * @param

Parameters type for the pipe */ -public abstract class CVPipe implements Function> { +public abstract class CVPipe implements Function> { - protected PipeResult result = new PipeResult<>(); + protected CVPipeResult result = new CVPipeResult<>(); protected P params; public void setParams(P params) { @@ -32,7 +32,7 @@ public abstract class CVPipe implements Function> { * @return Result of processing. */ @Override - public PipeResult apply(I in) { + public CVPipeResult apply(I in) { long pipeStartNanos = System.nanoTime(); result.result = process(in); result.nanosElapsed = System.nanoTime() - pipeStartNanos; diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/CVPipeResult.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/CVPipeResult.java new file mode 100644 index 000000000..4fbd96e46 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/CVPipeResult.java @@ -0,0 +1,6 @@ +package com.chameleonvision.common.vision.pipe; + +public class CVPipeResult { + public O result; + public long nanosElapsed; +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/ImageFlipMode.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/ImageFlipMode.java new file mode 100644 index 000000000..59345518c --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/ImageFlipMode.java @@ -0,0 +1,14 @@ +package com.chameleonvision.common.vision.pipe; + +public enum ImageFlipMode { + NONE(Integer.MIN_VALUE), + VERTICAL(1), + HORIZONTAL(0), + BOTH(-1); + + public final int value; + + ImageFlipMode(int value) { + this.value = value; + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/ImageRotationMode.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/ImageRotationMode.java new file mode 100644 index 000000000..517736d4e --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/ImageRotationMode.java @@ -0,0 +1,18 @@ +package com.chameleonvision.common.vision.pipe; + +public enum ImageRotationMode { + DEG_0(-1), + DEG_90(0), + DEG_180(1), + DEG_270(2); + + public final int value; + + ImageRotationMode(int value) { + this.value = value; + } + + public boolean isRotated() { + return this.value == DEG_90.value || this.value == DEG_270.value; + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/BlurPipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/BlurPipe.java similarity index 90% rename from chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/BlurPipe.java rename to chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/BlurPipe.java index e48008860..b32c5c6b1 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/BlurPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/BlurPipe.java @@ -1,6 +1,6 @@ -package com.chameleonvision.common.vision.pipeline.pipe; +package com.chameleonvision.common.vision.pipe.impl; -import com.chameleonvision.common.vision.pipeline.CVPipe; +import com.chameleonvision.common.vision.pipe.CVPipe; import org.opencv.core.Mat; import org.opencv.core.Size; import org.opencv.imgproc.Imgproc; diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/Collect2dTargetsPipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/Collect2dTargetsPipe.java similarity index 59% rename from chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/Collect2dTargetsPipe.java rename to chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/Collect2dTargetsPipe.java index c47c30d29..10e833ec6 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/Collect2dTargetsPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/Collect2dTargetsPipe.java @@ -1,10 +1,9 @@ -package com.chameleonvision.common.vision.pipeline.pipe; +package com.chameleonvision.common.vision.pipe.impl; import com.chameleonvision.common.util.numbers.DoubleCouple; -import com.chameleonvision.common.vision.camera.CaptureStaticProperties; -import com.chameleonvision.common.vision.pipeline.CVPipe; -import com.chameleonvision.common.vision.target.PotentialTarget; -import com.chameleonvision.common.vision.target.TrackedTarget; +import com.chameleonvision.common.vision.frame.FrameStaticProperties; +import com.chameleonvision.common.vision.pipe.CVPipe; +import com.chameleonvision.common.vision.target.*; import java.util.ArrayList; import java.util.List; import org.opencv.core.Point; @@ -26,15 +25,15 @@ public class Collect2dTargetsPipe var calculationParams = new TrackedTarget.TargetCalculationParameters( - params.getOrientation() == TrackedTarget.TargetOrientation.Landscape, + params.getOrientation() == TargetOrientation.Landscape, params.getOffsetPointRegion(), params.getUserOffsetPoint(), - params.getCaptureStaticProperties().centerPoint, + params.getFrameStaticProperties().centerPoint, new DoubleCouple(params.getCalibrationB(), params.getCalibrationM()), params.getOffsetMode(), - params.getCaptureStaticProperties().horizontalFocalLength, - params.getCaptureStaticProperties().verticalFocalLength, - params.getCaptureStaticProperties().imageArea); + params.getFrameStaticProperties().horizontalFocalLength, + params.getFrameStaticProperties().verticalFocalLength, + params.getFrameStaticProperties().imageArea); for (PotentialTarget target : in) { targets.add(new TrackedTarget(target, calculationParams)); @@ -44,21 +43,21 @@ public class Collect2dTargetsPipe } public static class Collect2dTargetsParams { - private CaptureStaticProperties m_captureStaticProperties; - private TrackedTarget.RobotOffsetPointMode m_offsetMode; + private FrameStaticProperties m_captureStaticProperties; + private RobotOffsetPointMode m_offsetMode; private double m_calibrationM, m_calibrationB; private Point m_userOffsetPoint; - private TrackedTarget.TargetOffsetPointRegion m_region; - private TrackedTarget.TargetOrientation m_orientation; + private TargetOffsetPointEdge m_region; + private TargetOrientation m_orientation; public Collect2dTargetsParams( - CaptureStaticProperties captureStaticProperties, - TrackedTarget.RobotOffsetPointMode offsetMode, + FrameStaticProperties captureStaticProperties, + RobotOffsetPointMode offsetMode, double calibrationM, double calibrationB, Point calibrationPoint, - TrackedTarget.TargetOffsetPointRegion region, - TrackedTarget.TargetOrientation orientation) { + TargetOffsetPointEdge region, + TargetOrientation orientation) { m_captureStaticProperties = captureStaticProperties; m_offsetMode = offsetMode; m_calibrationM = calibrationM; @@ -68,11 +67,11 @@ public class Collect2dTargetsPipe m_orientation = orientation; } - public CaptureStaticProperties getCaptureStaticProperties() { + public FrameStaticProperties getFrameStaticProperties() { return m_captureStaticProperties; } - public TrackedTarget.RobotOffsetPointMode getOffsetMode() { + public RobotOffsetPointMode getOffsetMode() { return m_offsetMode; } @@ -88,11 +87,11 @@ public class Collect2dTargetsPipe return m_userOffsetPoint; } - public TrackedTarget.TargetOffsetPointRegion getOffsetPointRegion() { + public TargetOffsetPointEdge getOffsetPointRegion() { return m_region; } - public TrackedTarget.TargetOrientation getOrientation() { + public TargetOrientation getOrientation() { return m_orientation; } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/CornerDetectionPipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/CornerDetectionPipe.java new file mode 100644 index 000000000..875b20d2e --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/CornerDetectionPipe.java @@ -0,0 +1,213 @@ +package com.chameleonvision.common.vision.pipe.impl; + +import com.chameleonvision.common.vision.pipe.CVPipe; +import com.chameleonvision.common.vision.target.TrackedTarget; +import edu.wpi.first.wpilibj.geometry.Translation2d; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import org.apache.commons.math3.util.FastMath; +import org.opencv.core.MatOfPoint2f; +import org.opencv.core.Point; +import org.opencv.imgproc.Imgproc; + +public class CornerDetectionPipe + extends CVPipe< + List, + List, + CornerDetectionPipe.CornerDetectionPipeParameters> { + + Comparator leftRightComparator = Comparator.comparingDouble(point -> point.x); + Comparator verticalComparator = Comparator.comparingDouble(point -> point.y); + MatOfPoint2f polyOutput = new MatOfPoint2f(); + + @Override + protected List process(List targetList) { + for (var target : targetList) { + // detect corners. Might implement more algorithms later but + // APPROX_POLY_DP_AND_EXTREME_CORNERS should be year agnostic + switch (params.cornerDetectionStrategy) { + case APPROX_POLY_DP_AND_EXTREME_CORNERS: + { + var targetCorners = + detectExtremeCornersByApproxPolyDp(target, params.calculateConvexHulls); + target.setCorners(targetCorners); + break; + } + default: + { + break; + } + } + } + return targetList; + } + + /** + * @param target the target to find the corners of. + * @return the corners. left top, left bottom, right bottom, right top + */ + 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 + // first, min by left/right + var list_ = Arrays.asList(points); + list_.sort(leftRightComparator); + // of this, we now have left and right + // sort to get top and bottom + var left = new ArrayList<>(List.of(list_.get(0), list_.get(1))); + left.sort(verticalComparator); + 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); + } + + /** + * @param a First point. + * @param b Second point. + * @return The straight line distance between them. + */ + private static double distanceBetween(Point a, Point b) { + return FastMath.sqrt(FastMath.pow(a.x - b.x, 2) + FastMath.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 FastMath.sqrt( + FastMath.pow(a.getX() - b.getX(), 2) + FastMath.pow(a.getY() - b.getY(), 2)); + } + + /** + * Find the 4 most extreme corners, + * + * @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. + */ + private List detectExtremeCornersByApproxPolyDp(TrackedTarget target, boolean convexHull) { + var centroid = target.getMinAreaRect().center; + Comparator distanceProvider = + Comparator.comparingDouble( + (Point point) -> + FastMath.sqrt( + FastMath.pow(centroid.x - point.x, 2) + FastMath.pow(centroid.y - point.y, 2))); + + MatOfPoint2f targetContour; + if (convexHull) { + targetContour = target.m_mainContour.getConvexHull(); + } else { + targetContour = target.m_mainContour.getMat2f(); + } + + /* + approximating a shape around the contours + Can be tuned to allow/disallow hulls + we want a number between 0 and 0.16 out of a percentage from 0 to 100 + so take accuracy and divide by 600 + + Furthermore, we know that the contour is open if we haven't done convex hulls + and it has subcontours. + */ + var isOpen = !convexHull && target.hasSubContours(); + var peri = Imgproc.arcLength(targetContour, true); + Imgproc.approxPolyDP( + targetContour, polyOutput, params.accuracyPercentage / 600.0 * peri, !isOpen); + + // we must have at least 4 corners for this strategy to work. + // If we are looking for an exact side count that is handled here too. + var pointList = new ArrayList<>(polyOutput.toList()); + if (pointList.size() < 4 || (params.exactSideCount && params.sideCount != pointList.size())) + return null; + + target.setApproximateBoundingPolygon(polyOutput); + + // 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 = + Comparator.comparingDouble((Point p) -> distanceBetween(p, boundingBoxCorners.get(3))); + + // top left and top right are the poly corners closest to the bouding box tl and tr + pointList.sort(distanceToTlComparator); + var tl = pointList.get(0); + pointList.remove(tl); + pointList.sort(distanceToTrComparator); + var tr = pointList.get(0); + pointList.remove(tr); + + // at this point we look for points on the left/right of the center of the remaining points + // and maximize their distance from the center of the min area rectangle + var leftList = new ArrayList(); + var rightList = new ArrayList(); + var averageXCoordinate = 0; + for (var p : pointList) { + averageXCoordinate += p.x; + } + averageXCoordinate /= pointList.size(); + + // add points that are below the center of the min area rectangle of the target + for (var p : pointList) { + if (p.y + > target.m_mainContour.getBoundingRect().y + + target.m_mainContour.getBoundingRect().height / 2.0) + if (p.x < averageXCoordinate) { + leftList.add(p); + } else { + rightList.add(p); + } + } + if (leftList.isEmpty() || rightList.isEmpty()) return null; + leftList.sort(distanceProvider); + rightList.sort(distanceProvider); + var bl = leftList.get(leftList.size() - 1); + var br = rightList.get(rightList.size() - 1); + System.out.printf("Found points: TL (%s) BL (%s) BR (%s) TR (%s)\n", tl, bl, br, tr); + return List.of(tl, bl, br, tr); + } + + public static class CornerDetectionPipeParameters { + private final DetectionStrategy cornerDetectionStrategy; + + private final boolean calculateConvexHulls; + private final boolean exactSideCount; + private final int sideCount; + + /** This number can be changed to change how "accurate" our approximate polygon must be. */ + private final double accuracyPercentage; + + public CornerDetectionPipeParameters( + DetectionStrategy cornerDetectionStrategy, + boolean calculateConvexHulls, + boolean exactSideCount, + int sideCount, + double accuracyPercentage) { + this.cornerDetectionStrategy = cornerDetectionStrategy; + this.calculateConvexHulls = calculateConvexHulls; + this.exactSideCount = exactSideCount; + this.sideCount = sideCount; + this.accuracyPercentage = accuracyPercentage; + } + } + + public enum DetectionStrategy { + APPROX_POLY_DP_AND_EXTREME_CORNERS + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/Draw2dContoursPipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/Draw2dContoursPipe.java similarity index 79% rename from chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/Draw2dContoursPipe.java rename to chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/Draw2dContoursPipe.java index 5611d1c15..95190d1a1 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/Draw2dContoursPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/Draw2dContoursPipe.java @@ -1,7 +1,7 @@ -package com.chameleonvision.common.vision.pipeline.pipe; +package com.chameleonvision.common.vision.pipe.impl; import com.chameleonvision.common.util.ColorHelper; -import com.chameleonvision.common.vision.pipeline.CVPipe; +import com.chameleonvision.common.vision.pipe.CVPipe; import com.chameleonvision.common.vision.target.TrackedTarget; import java.awt.Color; import java.util.ArrayList; @@ -21,8 +21,9 @@ public class Draw2dContoursPipe @Override protected Mat process(Pair> in) { - if (params.showCentroid || params.showMaximumBox || params.showRotatedBox) { - for (int i = 0; i < in.getRight().size(); i++) { + if (!in.getRight().isEmpty() + && (params.showCentroid || params.showMaximumBox || params.showRotatedBox)) { + for (int i = 0; i < (params.showMultiple ? in.getRight().size() : 1); i++) { Point[] vertices = new Point[4]; MatOfPoint contour = new MatOfPoint(); @@ -77,13 +78,18 @@ public class Draw2dContoursPipe } public static class Draw2dContoursParams { - public boolean showCentroid = false; - public boolean showMultiple = false; - public int boxOutlineSize = 0; - public boolean showRotatedBox = false; - public boolean showMaximumBox = false; + public boolean showCentroid = true; + public boolean showMultiple = true; + public int boxOutlineSize = 1; + public boolean showRotatedBox = true; + public boolean showMaximumBox = true; public Color centroidColor = Color.GREEN; public Color rotatedBoxColor = Color.BLUE; public Color maximumBoxColor = Color.RED; + + // TODO: set other params from UI/settings file? + public Draw2dContoursParams(boolean showMultipleTargets) { + this.showMultiple = showMultipleTargets; + } } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/Draw2dCrosshairPipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/Draw2dCrosshairPipe.java new file mode 100644 index 000000000..05ce9cadf --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/Draw2dCrosshairPipe.java @@ -0,0 +1,62 @@ +package com.chameleonvision.common.vision.pipe.impl; + +import com.chameleonvision.common.util.ColorHelper; +import com.chameleonvision.common.util.numbers.DoubleCouple; +import com.chameleonvision.common.vision.pipe.CVPipe; +import com.chameleonvision.common.vision.target.RobotOffsetPointMode; +import com.chameleonvision.common.vision.target.TrackedTarget; +import java.awt.Color; +import java.util.List; +import org.apache.commons.lang3.tuple.Pair; +import org.opencv.core.Mat; +import org.opencv.core.Point; +import org.opencv.imgproc.Imgproc; + +public class Draw2dCrosshairPipe + extends CVPipe>, Mat, Draw2dCrosshairPipe.Draw2dCrosshairParams> { + + @Override + protected Mat process(Pair> in) { + Mat image = in.getLeft(); + + if (params.m_showCrosshair) { + double x = image.cols() / 2.0; + double y = image.rows() / 2.0; + double scale = image.cols() / 32.0; + + switch (params.m_calibrationMode) { + case Single: + if (!params.m_calibrationPoint.isEmpty()) { + x = params.m_calibrationPoint.getFirst(); + y = params.m_calibrationPoint.getSecond(); + } + break; + case Dual: + // TODO + break; + } + + Point xMax = new Point(x + scale, y); + Point xMin = new Point(x - scale, y); + Point yMax = new Point(x, y + scale); + Point yMin = new Point(x, y - scale); + + Imgproc.line(image, xMax, xMin, ColorHelper.colorToScalar(params.m_crosshairColor)); + Imgproc.line(image, yMax, yMin, ColorHelper.colorToScalar(params.m_crosshairColor)); + } + return image; + } + + public static class Draw2dCrosshairParams { + private RobotOffsetPointMode m_calibrationMode; + private DoubleCouple m_calibrationPoint; + public boolean m_showCrosshair = true; + public Color m_crosshairColor = Color.GREEN; + + public Draw2dCrosshairParams( + RobotOffsetPointMode calibrationMode, DoubleCouple calibrationPoint) { + m_calibrationMode = calibrationMode; + m_calibrationPoint = calibrationPoint; + } + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/Draw3dTargetsPipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/Draw3dTargetsPipe.java new file mode 100644 index 000000000..1b5014121 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/Draw3dTargetsPipe.java @@ -0,0 +1,122 @@ +package com.chameleonvision.common.vision.pipe.impl; + +import com.chameleonvision.common.calibration.CameraCalibrationCoefficients; +import com.chameleonvision.common.util.ColorHelper; +import com.chameleonvision.common.vision.pipe.CVPipe; +import com.chameleonvision.common.vision.target.TargetModel; +import com.chameleonvision.common.vision.target.TrackedTarget; +import java.awt.*; +import java.util.List; +import org.apache.commons.lang3.tuple.Pair; +import org.opencv.calib3d.Calib3d; +import org.opencv.core.CvType; +import org.opencv.core.Mat; +import org.opencv.core.MatOfPoint; +import org.opencv.core.MatOfPoint2f; +import org.opencv.imgproc.Imgproc; + +public class Draw3dTargetsPipe + extends CVPipe>, Mat, Draw3dTargetsPipe.Draw3dContoursParams> { + + private static MatOfPoint tempMat = new MatOfPoint(); + + @Override + protected Mat process(Pair> in) { + for (var target : in.getRight()) { + + // draw convex hull + var pointMat = new MatOfPoint(); + target.m_mainContour.getConvexHull().convertTo(pointMat, CvType.CV_32S); + Imgproc.drawContours( + in.getLeft(), List.of(pointMat), -1, ColorHelper.colorToScalar(Color.green), 1); + + // draw approximate polygon + var poly = target.getApproximateBoundingPolygon(); + if (poly != null) { + poly.convertTo(pointMat, CvType.CV_32S); + Imgproc.drawContours( + in.getLeft(), List.of(pointMat), -1, ColorHelper.colorToScalar(Color.blue), 2); + } + + // Draw floor and top + if (target.getCameraRelativeRvec() != null && target.getCameraRelativeTvec() != null) { + var tempMat = new MatOfPoint2f(); + var jac = new Mat(); + var bottomModel = params.targetModel.getVisualizationBoxBottom(); + var topModel = params.targetModel.getVisualizationBoxTop(); + Calib3d.projectPoints( + bottomModel, + target.getCameraRelativeRvec(), + target.getCameraRelativeTvec(), + params.cameraCalibrationCoefficients.getCameraIntrinsicsMat(), + params.cameraCalibrationCoefficients.getCameraExtrinsicsMat(), + tempMat, + jac); + var bottomPoints = tempMat.toList(); + Calib3d.projectPoints( + topModel, + target.getCameraRelativeRvec(), + target.getCameraRelativeTvec(), + params.cameraCalibrationCoefficients.getCameraIntrinsicsMat(), + params.cameraCalibrationCoefficients.getCameraExtrinsicsMat(), + tempMat, + jac); + var topPoints = tempMat.toList(); + // floor, then pillers, then top + for (int i = 0; i < bottomPoints.size(); i++) { + Imgproc.line( + in.getLeft(), + bottomPoints.get(i), + bottomPoints.get((i + 1) % (bottomPoints.size())), + ColorHelper.colorToScalar(Color.green), + 3); + } + for (int i = 0; i < bottomPoints.size(); i++) { + Imgproc.line( + in.getLeft(), + bottomPoints.get(i), + topPoints.get(i), + ColorHelper.colorToScalar(Color.blue), + 3); + } + for (int i = 0; i < topPoints.size(); i++) { + Imgproc.line( + in.getLeft(), + topPoints.get(i), + topPoints.get((i + 1) % (bottomPoints.size())), + ColorHelper.colorToScalar(Color.orange), + 3); + } + + jac.release(); + } + pointMat.release(); + + // draw corners + var corners = target.getTargetCorners(); + if (corners != null && !corners.isEmpty()) { + for (var corner : corners) { + Imgproc.circle( + in.getLeft(), + corner, + params.radius, + ColorHelper.colorToScalar(params.color), + params.radius); + } + } + } + + return in.getLeft(); + } + + public static class Draw3dContoursParams { + private final int radius = 2; + private final Color color = Color.RED; + private final TargetModel targetModel = TargetModel.get2020Target(); + private final CameraCalibrationCoefficients cameraCalibrationCoefficients; + + public Draw3dContoursParams(CameraCalibrationCoefficients cameraCalibrationCoefficients) { + this.cameraCalibrationCoefficients = cameraCalibrationCoefficients; + } + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/DrawCornerDetectionPipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/DrawCornerDetectionPipe.java new file mode 100644 index 000000000..003f216e3 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/DrawCornerDetectionPipe.java @@ -0,0 +1,32 @@ +package com.chameleonvision.common.vision.pipe.impl; + +import com.chameleonvision.common.vision.pipe.CVPipe; +import com.chameleonvision.common.vision.target.TrackedTarget; +import java.util.List; +import org.apache.commons.lang3.tuple.Pair; +import org.opencv.core.Mat; +import org.opencv.core.Scalar; +import org.opencv.imgproc.Imgproc; + +public class DrawCornerDetectionPipe + extends CVPipe>, Mat, DrawCornerDetectionPipe.DrawCornerParams> { + + @Override + protected Mat process(Pair> in) { + Mat image = in.getLeft(); + + for (var target : in.getRight()) { + var corners = target.getTargetCorners(); + for (var corner : corners) { + Imgproc.circle(image, corner, params.dotRadius, params.dotColor); + } + } + + return image; + } + + public static class DrawCornerParams { + int dotRadius; + Scalar dotColor; + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/ErodeDilatePipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/ErodeDilatePipe.java similarity index 90% rename from chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/ErodeDilatePipe.java rename to chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/ErodeDilatePipe.java index f507703d4..01c75cc0c 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/ErodeDilatePipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/ErodeDilatePipe.java @@ -1,6 +1,6 @@ -package com.chameleonvision.common.vision.pipeline.pipe; +package com.chameleonvision.common.vision.pipe.impl; -import com.chameleonvision.common.vision.pipeline.CVPipe; +import com.chameleonvision.common.vision.pipe.CVPipe; import org.opencv.core.Mat; import org.opencv.core.Size; import org.opencv.imgproc.Imgproc; diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/FilterContoursPipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/FilterContoursPipe.java similarity index 88% rename from chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/FilterContoursPipe.java rename to chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/FilterContoursPipe.java index 076b78b23..27a533aec 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/FilterContoursPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/FilterContoursPipe.java @@ -1,10 +1,10 @@ -package com.chameleonvision.common.vision.pipeline.pipe; +package com.chameleonvision.common.vision.pipe.impl; import com.chameleonvision.common.util.math.MathUtils; import com.chameleonvision.common.util.numbers.DoubleCouple; -import com.chameleonvision.common.vision.camera.CaptureStaticProperties; +import com.chameleonvision.common.vision.frame.FrameStaticProperties; import com.chameleonvision.common.vision.opencv.Contour; -import com.chameleonvision.common.vision.pipeline.CVPipe; +import com.chameleonvision.common.vision.pipe.CVPipe; import java.util.ArrayList; import java.util.List; import org.opencv.core.Rect; @@ -56,13 +56,13 @@ public class FilterContoursPipe private DoubleCouple m_area; private DoubleCouple m_ratio; private DoubleCouple m_extent; - private CaptureStaticProperties m_camProperties; + private FrameStaticProperties m_camProperties; public FilterContoursParams( DoubleCouple area, DoubleCouple ratio, DoubleCouple extent, - CaptureStaticProperties camProperties) { + FrameStaticProperties camProperties) { this.m_area = area; this.m_ratio = ratio; this.m_extent = extent; @@ -81,7 +81,7 @@ public class FilterContoursPipe return m_extent; } - public CaptureStaticProperties getCamProperties() { + public FrameStaticProperties getCamProperties() { return m_camProperties; } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/FindContoursPipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/FindContoursPipe.java similarity index 88% rename from chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/FindContoursPipe.java rename to chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/FindContoursPipe.java index 789ab759b..37b960d50 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/FindContoursPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/FindContoursPipe.java @@ -1,7 +1,7 @@ -package com.chameleonvision.common.vision.pipeline.pipe; +package com.chameleonvision.common.vision.pipe.impl; import com.chameleonvision.common.vision.opencv.Contour; -import com.chameleonvision.common.vision.pipeline.CVPipe; +import com.chameleonvision.common.vision.pipe.CVPipe; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/FindShapesPipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/FindShapesPipe.java new file mode 100644 index 000000000..f10218418 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/FindShapesPipe.java @@ -0,0 +1,41 @@ +package com.chameleonvision.common.vision.pipe.impl; + +import com.chameleonvision.common.vision.opencv.CVShape; +import com.chameleonvision.common.vision.opencv.Contour; +import com.chameleonvision.common.vision.opencv.ContourShape; +import com.chameleonvision.common.vision.pipe.CVPipe; +import java.util.List; +import org.opencv.core.MatOfPoint2f; +import org.opencv.imgproc.Imgproc; + +public class FindShapesPipe + extends CVPipe, List, FindShapesPipe.FindShapesParams> { + + MatOfPoint2f approxCurve = new MatOfPoint2f(); + + @Override + protected List process(List in) { + approxCurve.release(); + approxCurve = new MatOfPoint2f(); + + for (var contour : in) { + + if (params.desiredShape == ContourShape.Circle) { + + } else { + int desiredSides = params.desiredShape.sides; + Imgproc.approxPolyDP(contour.getMat2f(), approxCurve, params.approxEpsilon, true); + + // int actualSides = approxCurve. + // switch () + System.out.println("fugg"); + } + } + return List.of(); + } + + public static class FindShapesParams { + double approxEpsilon = 0.05; + ContourShape desiredShape; + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/GroupContoursPipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/GroupContoursPipe.java similarity index 56% rename from chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/GroupContoursPipe.java rename to chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/GroupContoursPipe.java index 6a2bf9341..0962d9f5d 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/GroupContoursPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/GroupContoursPipe.java @@ -1,7 +1,9 @@ -package com.chameleonvision.common.vision.pipeline.pipe; +package com.chameleonvision.common.vision.pipe.impl; import com.chameleonvision.common.vision.opencv.Contour; -import com.chameleonvision.common.vision.pipeline.CVPipe; +import com.chameleonvision.common.vision.opencv.ContourGroupingMode; +import com.chameleonvision.common.vision.opencv.ContourIntersectionDirection; +import com.chameleonvision.common.vision.pipe.CVPipe; import com.chameleonvision.common.vision.target.PotentialTarget; import java.util.ArrayList; import java.util.Collections; @@ -14,9 +16,13 @@ public class GroupContoursPipe @Override protected List process(List input) { + for (var target : m_targets) { + target.release(); + } + m_targets.clear(); - if (params.getGroup() == Contour.ContourGrouping.Single) { + if (params.getGroup() == ContourGroupingMode.Single) { for (var contour : input) { m_targets.add(new PotentialTarget(contour)); } @@ -36,18 +42,23 @@ public class GroupContoursPipe // make a list of the desired count of contours to group List groupingSet; try { - groupingSet = input.subList(i, i + groupingCount - 1); + groupingSet = input.subList(i, i + groupingCount); } catch (IndexOutOfBoundsException e) { continue; } + try { - // FYI: This method only takes 2 contours! - Contour groupedContour = - Contour.groupContoursByIntersection( - groupingSet.get(0), groupingSet.get(1), params.getIntersection()); + // FYI: This method only takes 2 contours! + Contour groupedContour = + Contour.groupContoursByIntersection( + groupingSet.get(0), groupingSet.get(1), params.getIntersection()); - if (groupedContour != null) { - m_targets.add(new PotentialTarget(groupedContour, groupingSet)); + if (groupedContour != null) { + m_targets.add(new PotentialTarget(groupedContour, groupingSet)); + i += (groupingCount - 1); + } + } catch (Exception ex) { + ex.printStackTrace(); } } } @@ -56,20 +67,20 @@ public class GroupContoursPipe } public static class GroupContoursParams { - private Contour.ContourGrouping m_group; - private Contour.ContourIntersection m_intersection; + private ContourGroupingMode m_group; + private ContourIntersectionDirection m_intersection; public GroupContoursParams( - Contour.ContourGrouping group, Contour.ContourIntersection intersection) { + ContourGroupingMode group, ContourIntersectionDirection intersectionDirection) { m_group = group; - m_intersection = intersection; + m_intersection = intersectionDirection; } - public Contour.ContourGrouping getGroup() { + public ContourGroupingMode getGroup() { return m_group; } - public Contour.ContourIntersection getIntersection() { + public ContourIntersectionDirection getIntersection() { return m_intersection; } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/HSVPipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/HSVPipe.java similarity index 70% rename from chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/HSVPipe.java rename to chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/HSVPipe.java index c4c3b582b..aaea7cfbe 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/HSVPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/HSVPipe.java @@ -1,6 +1,7 @@ -package com.chameleonvision.common.vision.pipeline.pipe; +package com.chameleonvision.common.vision.pipe.impl; -import com.chameleonvision.common.vision.pipeline.CVPipe; +import com.chameleonvision.common.util.numbers.IntegerCouple; +import com.chameleonvision.common.vision.pipe.CVPipe; import org.opencv.core.Core; import org.opencv.core.CvException; import org.opencv.core.Mat; @@ -28,6 +29,11 @@ public class HSVPipe extends CVPipe { private Scalar m_hsvLower; private Scalar m_hsvUpper; + public HSVParams(IntegerCouple hue, IntegerCouple saturation, IntegerCouple value) { + m_hsvLower = new Scalar(hue.getFirst(), saturation.getFirst(), value.getFirst()); + m_hsvUpper = new Scalar(hue.getSecond(), saturation.getSecond(), value.getSecond()); + } + public HSVParams(Scalar hsvLower, Scalar hsvUpper) { m_hsvLower = hsvLower; m_hsvUpper = hsvUpper; diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/OutputMatPipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/OutputMatPipe.java similarity index 63% rename from chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/OutputMatPipe.java rename to chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/OutputMatPipe.java index d167b609e..c9bdda714 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/OutputMatPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/OutputMatPipe.java @@ -1,26 +1,29 @@ -package com.chameleonvision.common.vision.pipeline.pipe; +package com.chameleonvision.common.vision.pipe.impl; -import com.chameleonvision.common.vision.pipeline.CVPipe; -import org.apache.commons.lang3.tuple.Pair; +import com.chameleonvision.common.vision.opencv.DualMat; +import com.chameleonvision.common.vision.pipe.CVPipe; import org.opencv.core.CvException; import org.opencv.core.Mat; import org.opencv.imgproc.Imgproc; -public class OutputMatPipe extends CVPipe, Mat, OutputMatPipe.OutputMatParams> { +public class OutputMatPipe extends CVPipe { private Mat m_outputMat = new Mat(); @Override - protected Mat process(Pair in) { + protected Mat process(DualMat in) { + Mat rawCam = in.first; + Mat hsv = in.second; if (params.showThreshold()) { + // convert input mat try { - in.getRight().copyTo(m_outputMat); + hsv.copyTo(m_outputMat); Imgproc.cvtColor(m_outputMat, m_outputMat, Imgproc.COLOR_GRAY2BGR, 3); } catch (CvException e) { System.err.println("(OutputMatPipe) Exception thrown by OpenCV: \n" + e.getMessage()); } } else { - in.getLeft().copyTo(m_outputMat); + m_outputMat = rawCam; } return m_outputMat; diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/ResizeImagePipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/ResizeImagePipe.java similarity index 61% rename from chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/ResizeImagePipe.java rename to chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/ResizeImagePipe.java index c2aef7884..777bf2899 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/ResizeImagePipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/ResizeImagePipe.java @@ -1,6 +1,7 @@ -package com.chameleonvision.common.vision.pipeline.pipe; +package com.chameleonvision.common.vision.pipe.impl; -import com.chameleonvision.common.vision.pipeline.CVPipe; +import com.chameleonvision.common.vision.frame.FrameDivisor; +import com.chameleonvision.common.vision.pipe.CVPipe; import org.opencv.core.Mat; import org.opencv.core.Size; import org.opencv.imgproc.Imgproc; @@ -24,6 +25,14 @@ public class ResizeImagePipe extends CVPipe, List, SolvePNPPipe.SolvePNPPipeParams> { + + private MatOfPoint2f imagePoints = new MatOfPoint2f(); + + @Override + protected List process(List targetList) { + for (var target : targetList) { + calculateTargetPose(target); + } + return targetList; + } + + private void calculateTargetPose(TrackedTarget target) { + Pose2d targetPose; + + var corners = target.getTargetCorners(); + if (corners == null + || corners.isEmpty() + || params.cameraCoefficients.getCameraIntrinsicsMat() == null + || params.cameraCoefficients.getCameraExtrinsicsMat() == null) { + targetPose = new Pose2d(); + return; + } + this.imagePoints.fromList(corners); + + var rVec = new Mat(); + var tVec = new Mat(); + try { + Calib3d.solvePnP( + params.targetModel.getRealWorldTargetCoordinates(), + imagePoints, + params.cameraCoefficients.getCameraIntrinsicsMat(), + params.cameraCoefficients.getCameraExtrinsicsMat(), + rVec, + tVec); + } catch (Exception e) { + e.printStackTrace(); + return; + } + + target.setCameraRelativeTvec(tVec); + target.setCameraRelativeRvec(rVec); + + targetPose = correctLocationForCameraPitch(tVec, rVec, params.cameraPitchAngle); + + target.setRobotRelativePose(targetPose); + } + + Mat rotationMatrix = new Mat(); + Mat inverseRotationMatrix = new Mat(); + Mat pzeroWorld = new Mat(); + Mat kMat = new Mat(); + Mat scaledTvec; + + @SuppressWarnings("DuplicatedCode") // yes I know we have another solvePNP pipe + private Pose2d correctLocationForCameraPitch(Mat tVec, Mat rVec, Rotation2d cameraPitchAngle) { + // Algorithm from team 5190 Green Hope Falcons. Can also be found in Ligerbot's vision + // whitepaper + var tiltAngle = cameraPitchAngle.getRadians(); + + // the left/right distance to the target, unchanged by tilt. + var x = tVec.get(0, 0)[0]; + + // Z distance in the flat plane is given by + // Z_field = z cos theta + y sin theta. + // Z is the distance "out" of the camera (straight forward). + var zField = + tVec.get(2, 0)[0] * FastMath.cos(tiltAngle) + tVec.get(1, 0)[0] * FastMath.sin(tiltAngle); + + Calib3d.Rodrigues(rVec, rotationMatrix); + Core.transpose(rotationMatrix, inverseRotationMatrix); + + scaledTvec = matScale(tVec, -1); + + Core.gemm(inverseRotationMatrix, scaledTvec, 1, kMat, 0, pzeroWorld); + scaledTvec.release(); + + var angle2 = FastMath.atan2(pzeroWorld.get(0, 0)[0], pzeroWorld.get(2, 0)[0]); + + // target rotation is the rotation of the target relative to straight ahead. this number + // should be unchanged if the robot purely translated left/right. + var targetRotation = -angle2; // radians + + // We want a vector that is X forward and Y left. + // We have a Z_field (out of the camera projected onto the field), and an X left/right. + // so Z_field becomes X, and X becomes Y + + //noinspection SuspiciousNameCombination + var targetLocation = new Translation2d(zField, -x); + return new Pose2d(targetLocation, new Rotation2d(targetRotation)); + } + + /** + * 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 + */ + 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 Rotation2d cameraPitchAngle; + private final TargetModel targetModel; + + public SolvePNPPipeParams( + CameraCalibrationCoefficients cameraCoefficients, + Rotation2d cameraPitchAngle, + TargetModel targetModel) { + this.cameraCoefficients = cameraCoefficients; + this.cameraPitchAngle = cameraPitchAngle; + this.targetModel = targetModel; + } + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/SortContoursPipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/SortContoursPipe.java new file mode 100644 index 000000000..51dfba9d8 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/SortContoursPipe.java @@ -0,0 +1,64 @@ +package com.chameleonvision.common.vision.pipe.impl; + +import com.chameleonvision.common.vision.frame.FrameStaticProperties; +import com.chameleonvision.common.vision.opencv.ContourSortMode; +import com.chameleonvision.common.vision.pipe.CVPipe; +import com.chameleonvision.common.vision.target.PotentialTarget; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import org.apache.commons.math3.util.FastMath; + +public class SortContoursPipe + extends CVPipe< + List, List, SortContoursPipe.SortContoursParams> { + + private List m_sortedContours = new ArrayList<>(); + + @Override + protected List process(List in) { + m_sortedContours.clear(); + if (in.size() > 0) { + m_sortedContours.addAll(in); + if (params.getSortMode() != ContourSortMode.Centermost) { + m_sortedContours.sort(params.getSortMode().getComparator()); + } else { + m_sortedContours.sort(Comparator.comparingDouble(this::calcSquareCenterDistance)); + } + } + + return new ArrayList<>( + m_sortedContours.subList(0, Math.min(in.size(), params.getMaxTargets() - 1))); + } + + private double calcSquareCenterDistance(PotentialTarget rect) { + return FastMath.sqrt( + FastMath.pow(params.getCamProperties().centerX - rect.getMinAreaRect().center.x, 2) + + FastMath.pow(params.getCamProperties().centerY - rect.getMinAreaRect().center.y, 2)); + } + + public static class SortContoursParams { + private ContourSortMode m_sortMode; + private FrameStaticProperties m_camProperties; + private int m_maxTargets; + + public SortContoursParams( + ContourSortMode sortMode, FrameStaticProperties camProperties, int maxTargets) { + m_sortMode = sortMode; + m_camProperties = camProperties; + m_maxTargets = maxTargets; + } + + public ContourSortMode getSortMode() { + return m_sortMode; + } + + public FrameStaticProperties getCamProperties() { + return m_camProperties; + } + + public int getMaxTargets() { + return m_maxTargets; + } + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/SpeckleRejectPipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/SpeckleRejectPipe.java similarity index 91% rename from chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/SpeckleRejectPipe.java rename to chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/SpeckleRejectPipe.java index 8c101c3fb..2d918f889 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/SpeckleRejectPipe.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipe/impl/SpeckleRejectPipe.java @@ -1,7 +1,7 @@ -package com.chameleonvision.common.vision.pipeline.pipe; +package com.chameleonvision.common.vision.pipe.impl; import com.chameleonvision.common.vision.opencv.Contour; -import com.chameleonvision.common.vision.pipeline.CVPipe; +import com.chameleonvision.common.vision.pipe.CVPipe; import java.util.ArrayList; import java.util.List; diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/CVPipeline.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/CVPipeline.java new file mode 100644 index 000000000..cd846a802 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/CVPipeline.java @@ -0,0 +1,24 @@ +package com.chameleonvision.common.vision.pipeline; + +import com.chameleonvision.common.util.math.MathUtils; +import com.chameleonvision.common.vision.frame.Frame; +import com.chameleonvision.common.vision.frame.FrameStaticProperties; + +public abstract class CVPipeline { + + protected abstract void setPipeParams(S settings, FrameStaticProperties frameStaticProperties); + + protected abstract R process(Frame frame, S settings); + + public R run(Frame frame, S settings) { + long pipelineStartNanos = System.nanoTime(); + + setPipeParams(settings, frame.frameStaticProperties); + + R result = process(frame, settings); + + result.setLatencyMillis(MathUtils.nanosToMillis(System.nanoTime() - pipelineStartNanos)); + + return result; + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/CVPipelineResult.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/CVPipelineResult.java new file mode 100644 index 000000000..22145847b --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/CVPipelineResult.java @@ -0,0 +1,40 @@ +package com.chameleonvision.common.vision.pipeline; + +import com.chameleonvision.common.vision.frame.Frame; +import com.chameleonvision.common.vision.opencv.Releasable; +import com.chameleonvision.common.vision.target.TrackedTarget; +import java.util.List; + +public class CVPipelineResult implements Releasable { + private double latencyMillis; + public final double processingMillis; + public final List targets; + public final Frame outputFrame; + + public CVPipelineResult(double processingMillis, List targets, Frame outputFrame) { + this.processingMillis = processingMillis; + this.targets = targets; + + // TODO: is this the best way to go about this? + this.outputFrame = Frame.copyFrom(outputFrame); + } + + public boolean hasTargets() { + return !targets.isEmpty(); + } + + public void release() { + for (TrackedTarget tt : targets) { + tt.release(); + } + outputFrame.release(); + } + + public double getLatencyMillis() { + return latencyMillis; + } + + protected void setLatencyMillis(double latencyMillis) { + this.latencyMillis = latencyMillis; + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/CVPipelineSettings.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/CVPipelineSettings.java new file mode 100644 index 000000000..eb57ac9e1 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/CVPipelineSettings.java @@ -0,0 +1,19 @@ +package com.chameleonvision.common.vision.pipeline; + +import com.chameleonvision.common.vision.frame.FrameDivisor; +import com.chameleonvision.common.vision.pipe.ImageFlipMode; +import com.chameleonvision.common.vision.pipe.ImageRotationMode; + +public class CVPipelineSettings { + public int pipelineIndex = 0; + public PipelineType pipelineType = PipelineType.DriverMode; + public ImageFlipMode inputImageFlipMode = ImageFlipMode.NONE; + public ImageRotationMode inputImageRotationMode = ImageRotationMode.DEG_0; + public String pipelineNickname = "New Pipeline"; + public double cameraExposure = 50.0; + public double cameraBrightness = 50.0; + public double cameraGain = 50.0; + public int cameraVideoModeIndex = 0; + public FrameDivisor inputFrameDivisor = FrameDivisor.NONE; + public FrameDivisor outputFrameDivisor = FrameDivisor.NONE; +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/Calibration3dPipeline.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/Calibration3dPipeline.java new file mode 100644 index 000000000..c4fcfef8f --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/Calibration3dPipeline.java @@ -0,0 +1,3 @@ +package com.chameleonvision.common.vision.pipeline; + +public class Calibration3dPipeline {} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/ColoredShapePipeline.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/ColoredShapePipeline.java new file mode 100644 index 000000000..22ecc0055 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/ColoredShapePipeline.java @@ -0,0 +1,16 @@ +package com.chameleonvision.common.vision.pipeline; + +import com.chameleonvision.common.vision.frame.Frame; +import com.chameleonvision.common.vision.frame.FrameStaticProperties; + +public class ColoredShapePipeline + extends CVPipeline { + @Override + protected void setPipeParams( + ColoredShapePipelineSettings settings, FrameStaticProperties frameStaticProperties) {} + + @Override + protected CVPipelineResult process(Frame frame, ColoredShapePipelineSettings settings) { + return null; + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/ColoredShapePipelineSettings.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/ColoredShapePipelineSettings.java new file mode 100644 index 000000000..d61fbae92 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/ColoredShapePipelineSettings.java @@ -0,0 +1,7 @@ +package com.chameleonvision.common.vision.pipeline; + +import com.chameleonvision.common.vision.opencv.ContourShape; + +public class ColoredShapePipelineSettings extends CVPipelineSettings { + ContourShape desiredShape; +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/DriverModePipeline.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/DriverModePipeline.java new file mode 100644 index 000000000..10930bd1e --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/DriverModePipeline.java @@ -0,0 +1,57 @@ +package com.chameleonvision.common.vision.pipeline; + +import com.chameleonvision.common.util.math.MathUtils; +import com.chameleonvision.common.vision.frame.Frame; +import com.chameleonvision.common.vision.frame.FrameStaticProperties; +import com.chameleonvision.common.vision.opencv.CVMat; +import com.chameleonvision.common.vision.pipe.impl.Draw2dCrosshairPipe; +import com.chameleonvision.common.vision.pipe.impl.ResizeImagePipe; +import com.chameleonvision.common.vision.pipe.impl.RotateImagePipe; +import java.util.List; +import org.apache.commons.lang3.tuple.Pair; + +public class DriverModePipeline + extends CVPipeline { + + private final RotateImagePipe rotateImagePipe = new RotateImagePipe(); + + private final ResizeImagePipe resizeImagePipe = new ResizeImagePipe(); + + private final Draw2dCrosshairPipe draw2dCrosshairPipe = new Draw2dCrosshairPipe(); + + @Override + protected void setPipeParams( + DriverModePipelineSettings settings, FrameStaticProperties frameStaticProperties) { + RotateImagePipe.RotateImageParams rotateImageParams = + new RotateImagePipe.RotateImageParams(settings.inputImageRotationMode); + rotateImagePipe.setParams(rotateImageParams); + + ResizeImagePipe.ResizeImageParams resizeImageParams = + new ResizeImagePipe.ResizeImageParams(settings.inputFrameDivisor); + resizeImagePipe.setParams(resizeImageParams); + + Draw2dCrosshairPipe.Draw2dCrosshairParams draw2dCrosshairParams = + new Draw2dCrosshairPipe.Draw2dCrosshairParams( + settings.offsetPointMode, settings.offsetPoint); + draw2dCrosshairPipe.setParams(draw2dCrosshairParams); + } + + @Override + public DriverModePipelineResult process(Frame frame, DriverModePipelineSettings settings) { + // apply pipes + var rotateImageResult = rotateImagePipe.apply(frame.image.getMat()); + var resizeImageResult = resizeImagePipe.apply(rotateImageResult.result); + var draw2dCrosshairResult = + draw2dCrosshairPipe.apply(Pair.of(resizeImageResult.result, List.of())); + + // calculate elapsed nanoseconds + long totalNanos = + rotateImageResult.nanosElapsed + + resizeImageResult.nanosElapsed + + draw2dCrosshairResult.nanosElapsed; + + return new DriverModePipelineResult( + MathUtils.nanosToMillis(totalNanos), + new Frame(new CVMat(draw2dCrosshairResult.result), frame.frameStaticProperties)); + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/DriverModePipelineResult.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/DriverModePipelineResult.java new file mode 100644 index 000000000..e8846c00f --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/DriverModePipelineResult.java @@ -0,0 +1,10 @@ +package com.chameleonvision.common.vision.pipeline; + +import com.chameleonvision.common.vision.frame.Frame; +import java.util.List; + +public class DriverModePipelineResult extends CVPipelineResult { + public DriverModePipelineResult(double latencyMillis, Frame outputFrame) { + super(latencyMillis, List.of(), outputFrame); + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/DriverModePipelineSettings.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/DriverModePipelineSettings.java new file mode 100644 index 000000000..458507485 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/DriverModePipelineSettings.java @@ -0,0 +1,9 @@ +package com.chameleonvision.common.vision.pipeline; + +import com.chameleonvision.common.util.numbers.DoubleCouple; +import com.chameleonvision.common.vision.target.RobotOffsetPointMode; + +public class DriverModePipelineSettings extends CVPipelineSettings { + public RobotOffsetPointMode offsetPointMode = RobotOffsetPointMode.None; + public DoubleCouple offsetPoint = new DoubleCouple(); +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/DummyPipeline.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/DummyPipeline.java deleted file mode 100644 index d5302a0b8..000000000 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/DummyPipeline.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.chameleonvision.common.vision.pipeline; - -import com.chameleonvision.common.vision.pipeline.pipe.ResizeImagePipe; -import com.chameleonvision.common.vision.pipeline.pipe.RotateImagePipe; -import edu.wpi.cscore.CameraServerCvJNI; -import java.io.IOException; -import org.opencv.core.CvType; -import org.opencv.core.Mat; - -/** This class exists for the sole purpose of showing how pipes would interact in a pipeline */ -public class DummyPipeline { - private static ResizeImagePipe resizePipe = new ResizeImagePipe(); - private static RotateImagePipe rotatePipe = new RotateImagePipe(); - - public static void main(String[] args) { - try { - CameraServerCvJNI.forceLoad(); - } catch (UnsatisfiedLinkError | IOException e) { - throw new RuntimeException("Failed to load JNI Libraries!"); - } - - // obviously not a useful test, purely for example. - Mat fakeCameraMat = new Mat(640, 480, CvType.CV_8UC3); - - PipeResult resizeResult = resizePipe.apply(fakeCameraMat); - PipeResult rotateResult = rotatePipe.apply(resizeResult.result); - - long fullTime = resizeResult.nanosElapsed + rotateResult.nanosElapsed; - System.out.println(fullTime / 1.0e+6 + "ms elapsed"); - } -} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/PipeResult.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/PipeResult.java deleted file mode 100644 index 451b172eb..000000000 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/PipeResult.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.chameleonvision.common.vision.pipeline; - -public class PipeResult { - O result; - long nanosElapsed; -} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/PipelineType.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/PipelineType.java new file mode 100644 index 000000000..f49154fb7 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/PipelineType.java @@ -0,0 +1,17 @@ +package com.chameleonvision.common.vision.pipeline; + +@SuppressWarnings("rawtypes") +public enum PipelineType { + Calib3d(-2, Calibration3dPipeline.class), + DriverMode(-1, DriverModePipeline.class), + Reflective(0, ReflectivePipeline.class), + ColoredShape(0, ColoredShapePipeline.class); + + public final int baseIndex; + public final Class clazz; + + PipelineType(int baseIndex, Class clazz) { + this.baseIndex = baseIndex; + this.clazz = clazz; + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/ReflectivePipeline.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/ReflectivePipeline.java new file mode 100644 index 000000000..6864ead35 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/ReflectivePipeline.java @@ -0,0 +1,228 @@ +package com.chameleonvision.common.vision.pipeline; + +import com.chameleonvision.common.util.math.MathUtils; +import com.chameleonvision.common.vision.frame.Frame; +import com.chameleonvision.common.vision.frame.FrameStaticProperties; +import com.chameleonvision.common.vision.opencv.CVMat; +import com.chameleonvision.common.vision.opencv.Contour; +import com.chameleonvision.common.vision.opencv.DualMat; +import com.chameleonvision.common.vision.pipe.CVPipeResult; +import com.chameleonvision.common.vision.pipe.impl.Collect2dTargetsPipe; +import com.chameleonvision.common.vision.pipe.impl.CornerDetectionPipe; +import com.chameleonvision.common.vision.pipe.impl.Draw2dContoursPipe; +import com.chameleonvision.common.vision.pipe.impl.Draw2dCrosshairPipe; +import com.chameleonvision.common.vision.pipe.impl.Draw3dTargetsPipe; +import com.chameleonvision.common.vision.pipe.impl.ErodeDilatePipe; +import com.chameleonvision.common.vision.pipe.impl.FilterContoursPipe; +import com.chameleonvision.common.vision.pipe.impl.FindContoursPipe; +import com.chameleonvision.common.vision.pipe.impl.GroupContoursPipe; +import com.chameleonvision.common.vision.pipe.impl.HSVPipe; +import com.chameleonvision.common.vision.pipe.impl.OutputMatPipe; +import com.chameleonvision.common.vision.pipe.impl.RotateImagePipe; +import com.chameleonvision.common.vision.pipe.impl.SolvePNPPipe; +import com.chameleonvision.common.vision.pipe.impl.SortContoursPipe; +import com.chameleonvision.common.vision.pipe.impl.SpeckleRejectPipe; +import com.chameleonvision.common.vision.target.PotentialTarget; +import com.chameleonvision.common.vision.target.TrackedTarget; +import java.util.List; +import org.apache.commons.lang3.tuple.Pair; +import org.opencv.core.Mat; + +/** Represents a pipeline for tracking retro-reflective targets. */ +public class ReflectivePipeline extends CVPipeline { + + private final RotateImagePipe rotateImagePipe = new RotateImagePipe(); + private final ErodeDilatePipe erodeDilatePipe = new ErodeDilatePipe(); + private final HSVPipe hsvPipe = new HSVPipe(); + private final OutputMatPipe outputMatPipe = new OutputMatPipe(); + private final FindContoursPipe findContoursPipe = new FindContoursPipe(); + private final SpeckleRejectPipe speckleRejectPipe = new SpeckleRejectPipe(); + private final FilterContoursPipe filterContoursPipe = new FilterContoursPipe(); + private final GroupContoursPipe groupContoursPipe = new GroupContoursPipe(); + private final SortContoursPipe sortContoursPipe = new SortContoursPipe(); + private final Collect2dTargetsPipe collect2dTargetsPipe = new Collect2dTargetsPipe(); + private final CornerDetectionPipe cornerDetectionPipe = new CornerDetectionPipe(); + private final SolvePNPPipe solvePNPPipe = new SolvePNPPipe(); + private final Draw2dCrosshairPipe draw2dCrosshairPipe = new Draw2dCrosshairPipe(); + private final Draw2dContoursPipe draw2dContoursPipe = new Draw2dContoursPipe(); + private final Draw3dTargetsPipe draw3dTargetsPipe = new Draw3dTargetsPipe(); + + private Mat rawInputMat = new Mat(); + private DualMat outputMats = new DualMat(); + + @Override + protected void setPipeParams( + ReflectivePipelineSettings settings, FrameStaticProperties frameStaticProperties) { + RotateImagePipe.RotateImageParams rotateImageParams = + new RotateImagePipe.RotateImageParams(settings.inputImageRotationMode); + rotateImagePipe.setParams(rotateImageParams); + + ErodeDilatePipe.ErodeDilateParams erodeDilateParams = + new ErodeDilatePipe.ErodeDilateParams( + settings.erode, settings.dilate, 5); // TODO: add kernel size to + // pipeline settings + erodeDilatePipe.setParams(erodeDilateParams); + + HSVPipe.HSVParams hsvParams = + new HSVPipe.HSVParams(settings.hsvHue, settings.hsvSaturation, settings.hsvValue); + hsvPipe.setParams(hsvParams); + + OutputMatPipe.OutputMatParams outputMatParams = + new OutputMatPipe.OutputMatParams(settings.outputShowThresholded); + outputMatPipe.setParams(outputMatParams); + + // TODO: necessary? offer different contour methods? + FindContoursPipe.FindContoursParams findContoursParams = + new FindContoursPipe.FindContoursParams(); + findContoursPipe.setParams(findContoursParams); + + SpeckleRejectPipe.SpeckleRejectParams speckleRejectParams = + new SpeckleRejectPipe.SpeckleRejectParams(settings.contourSpecklePercentage); + speckleRejectPipe.setParams(speckleRejectParams); + + FilterContoursPipe.FilterContoursParams filterContoursParams = + new FilterContoursPipe.FilterContoursParams( + settings.contourArea, + settings.contourRatio, + settings.contourExtent, + frameStaticProperties); + filterContoursPipe.setParams(filterContoursParams); + + GroupContoursPipe.GroupContoursParams groupContoursParams = + new GroupContoursPipe.GroupContoursParams( + settings.contourGroupingMode, settings.contourIntersection); + groupContoursPipe.setParams(groupContoursParams); + + SortContoursPipe.SortContoursParams sortContoursParams = + new SortContoursPipe.SortContoursParams(settings.contourSortMode, frameStaticProperties, 5); + sortContoursPipe.setParams(sortContoursParams); + + Collect2dTargetsPipe.Collect2dTargetsParams collect2dTargetsParams = + new Collect2dTargetsPipe.Collect2dTargetsParams( + frameStaticProperties, + settings.offsetRobotOffsetMode, + settings.offsetDualLineM, + settings.offsetDualLineB, + settings.offsetCalibrationPoint.toPoint(), + settings.contourTargetOffsetPointEdge, + settings.contourTargetOrientation); + collect2dTargetsPipe.setParams(collect2dTargetsParams); + + var params = + new CornerDetectionPipe.CornerDetectionPipeParameters( + settings.cornerDetectionStrategy, + settings.cornerDetectionUseConvexHulls, + settings.cornerDetectionExactSideCount, + settings.cornerDetectionSideCount, + settings.cornerDetectionAccuracyPercentage); + cornerDetectionPipe.setParams(params); + + Draw2dContoursPipe.Draw2dContoursParams draw2dContoursParams = + new Draw2dContoursPipe.Draw2dContoursParams(settings.outputShowMultipleTargets); + draw2dContoursPipe.setParams(draw2dContoursParams); + + Draw2dCrosshairPipe.Draw2dCrosshairParams draw2dCrosshairParams = + new Draw2dCrosshairPipe.Draw2dCrosshairParams( + settings.offsetRobotOffsetMode, settings.offsetCalibrationPoint); + draw2dCrosshairPipe.setParams(draw2dCrosshairParams); + + var draw3dContoursParams = + new Draw3dTargetsPipe.Draw3dContoursParams(settings.cameraCalibration); + draw3dTargetsPipe.setParams(draw3dContoursParams); + + var solvePNPParams = + new SolvePNPPipe.SolvePNPPipeParams( + settings.cameraCalibration, settings.cameraPitch, settings.targetModel); + solvePNPPipe.setParams(solvePNPParams); + } + + @Override + public CVPipelineResult process(Frame frame, ReflectivePipelineSettings settings) { + setPipeParams(settings, frame.frameStaticProperties); + + long sumPipeNanosElapsed = 0L; + + frame.image.getMat().copyTo(rawInputMat); + + CVPipeResult rotateImageResult = rotateImagePipe.apply(frame.image.getMat()); + sumPipeNanosElapsed += rotateImageResult.nanosElapsed; + + CVPipeResult erodeDilateResult = erodeDilatePipe.apply(rotateImageResult.result); + sumPipeNanosElapsed += erodeDilateResult.nanosElapsed; + + CVPipeResult hsvPipeResult = hsvPipe.apply(erodeDilateResult.result); + sumPipeNanosElapsed += hsvPipeResult.nanosElapsed; + + // mat leak fix attempt + outputMats.first = rawInputMat; + outputMats.second = hsvPipeResult.result; + + CVPipeResult outputMatResult = outputMatPipe.apply(outputMats); + sumPipeNanosElapsed += outputMatResult.nanosElapsed; + + CVPipeResult> findContoursResult = findContoursPipe.apply(hsvPipeResult.result); + sumPipeNanosElapsed += findContoursResult.nanosElapsed; + + CVPipeResult> speckleRejectResult = + speckleRejectPipe.apply(findContoursResult.result); + sumPipeNanosElapsed += speckleRejectResult.nanosElapsed; + + CVPipeResult> groupContoursResult = + groupContoursPipe.apply(speckleRejectResult.result); + sumPipeNanosElapsed += groupContoursResult.nanosElapsed; + + CVPipeResult> sortContoursResult = + sortContoursPipe.apply(groupContoursResult.result); + sumPipeNanosElapsed += sortContoursResult.nanosElapsed; + + CVPipeResult> collect2dTargetsResult = + collect2dTargetsPipe.apply(sortContoursResult.result); + sumPipeNanosElapsed += collect2dTargetsResult.nanosElapsed; + + CVPipeResult> targetList; + + // 3d stuff + if (settings.solvePNPEnabled) { + var cornerDetectionResult = cornerDetectionPipe.apply(collect2dTargetsResult.result); + sumPipeNanosElapsed += cornerDetectionResult.nanosElapsed; + + var solvePNPResult = solvePNPPipe.apply(cornerDetectionResult.result); + sumPipeNanosElapsed += solvePNPResult.nanosElapsed; + + targetList = solvePNPResult; + } else { + targetList = collect2dTargetsResult; + } + + CVPipeResult result; + + CVPipeResult draw2dCrosshairResult = + draw2dCrosshairPipe.apply(Pair.of(outputMatResult.result, targetList.result)); + sumPipeNanosElapsed += draw2dCrosshairResult.nanosElapsed; + + CVPipeResult draw2dContoursResult = + draw2dContoursPipe.apply( + Pair.of(draw2dCrosshairResult.result, collect2dTargetsResult.result)); + sumPipeNanosElapsed += draw2dContoursResult.nanosElapsed; + + if (settings.solvePNPEnabled) { + result = + draw3dTargetsPipe.apply( + Pair.of(draw2dCrosshairResult.result, collect2dTargetsResult.result)); + sumPipeNanosElapsed += result.nanosElapsed; + } else { + result = draw2dContoursResult; + } + + // TODO: better way? + if (settings.outputShowThresholded) { + rawInputMat.release(); + } + + // TODO: Implement all the things + return new CVPipelineResult( + MathUtils.nanosToMillis(sumPipeNanosElapsed), + collect2dTargetsResult.result, + new Frame(new CVMat(result.result), frame.frameStaticProperties)); + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/ReflectivePipelineSettings.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/ReflectivePipelineSettings.java new file mode 100644 index 000000000..a46428072 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/ReflectivePipelineSettings.java @@ -0,0 +1,73 @@ +package com.chameleonvision.common.vision.pipeline; + +import com.chameleonvision.common.calibration.CameraCalibrationCoefficients; +import com.chameleonvision.common.util.numbers.DoubleCouple; +import com.chameleonvision.common.util.numbers.IntegerCouple; +import com.chameleonvision.common.vision.opencv.ContourGroupingMode; +import com.chameleonvision.common.vision.opencv.ContourIntersectionDirection; +import com.chameleonvision.common.vision.opencv.ContourSortMode; +import com.chameleonvision.common.vision.pipe.impl.CornerDetectionPipe; +import com.chameleonvision.common.vision.target.RobotOffsetPointMode; +import com.chameleonvision.common.vision.target.TargetModel; +import com.chameleonvision.common.vision.target.TargetOffsetPointEdge; +import com.chameleonvision.common.vision.target.TargetOrientation; +import edu.wpi.first.wpilibj.geometry.Rotation2d; + +public class ReflectivePipelineSettings extends CVPipelineSettings { + public IntegerCouple hsvHue = new IntegerCouple(50, 180); + public IntegerCouple hsvSaturation = new IntegerCouple(50, 255); + public IntegerCouple hsvValue = new IntegerCouple(50, 255); + + public boolean outputShowThresholded = false; + public boolean outputShowMultipleTargets = false; + + public boolean erode = false; + public boolean dilate = false; + + public DoubleCouple contourArea = new DoubleCouple(0.0, 100.0); + public DoubleCouple contourRatio = new DoubleCouple(0.0, 20.0); + public DoubleCouple contourExtent = new DoubleCouple(0.0, 100.0); + public int contourSpecklePercentage = 5; + + // the order in which to sort contours to find the most desirable + public ContourSortMode contourSortMode = ContourSortMode.Largest; + + // the edge (or not) of the target to consider the center point (Top, Bottom, Left, Right, + // Center) + public TargetOffsetPointEdge contourTargetOffsetPointEdge = TargetOffsetPointEdge.Center; + + // orientation of the target in terms of aspect ratio + public TargetOrientation contourTargetOrientation = TargetOrientation.Landscape; + + // how many contours to attempt to group (Single, Dual) + public ContourGroupingMode contourGroupingMode = ContourGroupingMode.Single; + + // the direction in which contours must intersect to be considered intersecting + public ContourIntersectionDirection contourIntersection = ContourIntersectionDirection.Up; + + // the mode in which to offset target center point based on the camera being offset on the + // robot + // (None, Single Point, Dual Point) + public RobotOffsetPointMode offsetRobotOffsetMode = RobotOffsetPointMode.None; + + // the point set by the user in Single Point Offset mode (maybe double too? idr) + public DoubleCouple offsetCalibrationPoint = new DoubleCouple(); + + // the two values that define the line of the Dual Point Offset calibration (think y=mx+b) + public double offsetDualLineM = 1; + public double offsetDualLineB = 0; + + // 3d settings + public boolean solvePNPEnabled = false; + public CameraCalibrationCoefficients cameraCalibration; + public TargetModel targetModel; + public Rotation2d cameraPitch; + + // Corner detection settings + public CornerDetectionPipe.DetectionStrategy cornerDetectionStrategy = + CornerDetectionPipe.DetectionStrategy.APPROX_POLY_DP_AND_EXTREME_CORNERS; + public boolean cornerDetectionUseConvexHulls = true; + public boolean cornerDetectionExactSideCount = false; + public int cornerDetectionSideCount = 4; + public double cornerDetectionAccuracyPercentage = 10; +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/Draw2dCrosshairPipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/Draw2dCrosshairPipe.java deleted file mode 100644 index 19a3bf491..000000000 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/Draw2dCrosshairPipe.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.chameleonvision.common.vision.pipeline.pipe; - -import com.chameleonvision.common.util.ColorHelper; -import com.chameleonvision.common.vision.pipeline.CVPipe; -import com.chameleonvision.common.vision.target.TrackedTarget; -import java.awt.Color; -import java.util.List; -import org.apache.commons.lang3.tuple.Pair; -import org.opencv.core.Mat; -import org.opencv.core.Point; -import org.opencv.imgproc.Imgproc; - -public class Draw2dCrosshairPipe - extends CVPipe>, Mat, Draw2dCrosshairPipe.Draw2dCrosshairParams> { - - @Override - protected Mat process(Pair> in) { - Mat image = in.getLeft(); - - double x, y; - double scale = image.cols() / 32.0; - - if (params.showCrosshair) { - x = image.cols() / 2.0; - y = image.rows() / 2.0; - - switch (params.calibrationMode) { - case Single: - if (params.calibrationPoint.equals(new Point())) { - params.calibrationPoint.set(new double[] {x, y}); - } - x = (int) params.calibrationPoint.x; - y = (int) params.calibrationPoint.y; - break; - case Dual: - // TODO - break; - } - Point xMax = new Point(x + scale, y); - Point xMin = new Point(x - scale, y); - Point yMax = new Point(x, y + scale); - Point yMin = new Point(x, y - scale); - - Imgproc.line(in.getLeft(), xMax, xMin, ColorHelper.colorToScalar(params.crosshairColor)); - Imgproc.line(in.getLeft(), yMax, yMin, ColorHelper.colorToScalar(params.crosshairColor)); - } - return in.getLeft(); - } - - public static class Draw2dCrosshairParams { - public TrackedTarget.RobotOffsetPointMode calibrationMode; - public Point calibrationPoint; - public boolean showCrosshair = true; - public Color crosshairColor = Color.GREEN; - } -} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/SortContoursPipe.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/SortContoursPipe.java deleted file mode 100644 index d9f648ba7..000000000 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/pipeline/pipe/SortContoursPipe.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.chameleonvision.common.vision.pipeline.pipe; - -import com.chameleonvision.common.vision.camera.CaptureStaticProperties; -import com.chameleonvision.common.vision.pipeline.CVPipe; -import com.chameleonvision.common.vision.target.TrackedTarget; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import org.apache.commons.math3.util.FastMath; - -public class SortContoursPipe - extends CVPipe, List, SortContoursPipe.SortContoursParams> { - - private List m_sortedContours = new ArrayList<>(); - - @Override - protected List process(List in) { - m_sortedContours.clear(); - if (in.size() > 0) { - m_sortedContours.addAll(in); - if (params.getSortMode() != SortMode.Centermost) { - m_sortedContours.sort(params.getSortMode().getComparator()); - } else { - m_sortedContours.sort(Comparator.comparingDouble(this::calcSquareCenterDistance)); - } - } - - return new ArrayList<>( - m_sortedContours.subList(0, Math.min(in.size(), params.getMaxTargets() - 1))); - } - - private double calcSquareCenterDistance(TrackedTarget rect) { - return FastMath.sqrt( - FastMath.pow(params.getCamProperties().centerX - rect.getMinAreaRect().center.x, 2) - + FastMath.pow(params.getCamProperties().centerY - rect.getMinAreaRect().center.y, 2)); - } - - public enum SortMode { - Largest( - (rect1, rect2) -> - Double.compare(rect2.getMinAreaRect().size.area(), rect1.getMinAreaRect().size.area())), - Smallest(Largest.getComparator().reversed()), - Highest(Comparator.comparingDouble(rect -> rect.getMinAreaRect().center.y)), - Lowest(Highest.getComparator().reversed()), - Leftmost(Comparator.comparingDouble(target -> target.getMinAreaRect().center.x)), - Rightmost(Leftmost.getComparator().reversed()), - Centermost(null); - - private Comparator m_comparator; - - SortMode(Comparator comparator) { - m_comparator = comparator; - } - - public Comparator getComparator() { - return m_comparator; - } - } - - public static class SortContoursParams { - private SortMode m_sortMode; - private CaptureStaticProperties m_camProperties; - private int m_maxTargets; - - public SortContoursParams( - SortMode sortMode, CaptureStaticProperties camProperties, int maxTargets) { - m_sortMode = sortMode; - m_camProperties = camProperties; - m_maxTargets = maxTargets; - } - - public SortMode getSortMode() { - return m_sortMode; - } - - public CaptureStaticProperties getCamProperties() { - return m_camProperties; - } - - public int getMaxTargets() { - return m_maxTargets; - } - } -} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/PotentialTarget.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/PotentialTarget.java index 0b7e4468b..8df7751e1 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/PotentialTarget.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/PotentialTarget.java @@ -1,13 +1,15 @@ package com.chameleonvision.common.vision.target; import com.chameleonvision.common.vision.opencv.Contour; +import com.chameleonvision.common.vision.opencv.Releasable; import java.util.ArrayList; import java.util.List; +import org.opencv.core.RotatedRect; -public class PotentialTarget { +public class PotentialTarget implements Releasable { - final Contour m_mainContour; - final List m_subContours; + public final Contour m_mainContour; + public final List m_subContours; public PotentialTarget(Contour inputContour) { m_mainContour = inputContour; @@ -16,6 +18,23 @@ public class PotentialTarget { public PotentialTarget(Contour inputContour, List subContours) { m_mainContour = inputContour; - m_subContours = subContours; + m_subContours = new ArrayList<>(subContours); + } + + public RotatedRect getMinAreaRect() { + return m_mainContour.getMinAreaRect(); + } + + public double getArea() { + return m_mainContour.getArea(); + } + + @Override + public void release() { + m_mainContour.release(); + for (var sc : m_subContours) { + sc.release(); + } + m_subContours.clear(); } } diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/RobotOffsetPointMode.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/RobotOffsetPointMode.java new file mode 100644 index 000000000..c582874a6 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/RobotOffsetPointMode.java @@ -0,0 +1,7 @@ +package com.chameleonvision.common.vision.target; + +public enum RobotOffsetPointMode { + None, + Single, + Dual +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/TargetModel.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/TargetModel.java new file mode 100644 index 000000000..cf073c37a --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/TargetModel.java @@ -0,0 +1,75 @@ +package com.chameleonvision.common.vision.target; + +import com.chameleonvision.common.vision.opencv.Releasable; +import java.util.ArrayList; +import java.util.List; +import org.opencv.core.MatOfPoint3f; +import org.opencv.core.Point3; + +public class TargetModel implements Releasable { + + private final MatOfPoint3f realWorldTargetCoordinates; + + private final MatOfPoint3f visualizationBoxBottom = new MatOfPoint3f(); + private final MatOfPoint3f visualizationBoxTop = new MatOfPoint3f(); + + public TargetModel(MatOfPoint3f realWorldTargetCoordinates, double boxHeight) { + this.realWorldTargetCoordinates = realWorldTargetCoordinates; + + var bottomList = realWorldTargetCoordinates.toList(); + var topList = new ArrayList(); + for (var c : bottomList) { + topList.add(new Point3(c.x, c.y, c.z + boxHeight)); + } + + this.visualizationBoxBottom.fromList(bottomList); + this.visualizationBoxTop.fromList(topList); + } + + public TargetModel(List points, double boxHeight) { + this(listToMat(points), boxHeight); + } + + private static MatOfPoint3f listToMat(List points) { + var mat = new MatOfPoint3f(); + mat.fromList(points); + return mat; + } + + public MatOfPoint3f getRealWorldTargetCoordinates() { + return realWorldTargetCoordinates; + } + + public MatOfPoint3f getVisualizationBoxBottom() { + return visualizationBoxBottom; + } + + public MatOfPoint3f getVisualizationBoxTop() { + return visualizationBoxTop; + } + + public static TargetModel get2020Target() { + return get2020Target(0); + } + + public static TargetModel get2020TargetInnerPort() { + return get2020Target(2d * 12d + 5.25); // Inches, TODO switch to meters + } + + public static TargetModel get2020Target(double offset) { + var corners = + List.of( + new Point3(-19.625, 0, offset), + new Point3(-9.819867, -17, offset), + new Point3(9.819867, -17, offset), + new Point3(19.625, 0, offset)); + return new TargetModel(corners, 12); // TODO switch to meters + } + + @Override + public void release() { + realWorldTargetCoordinates.release(); + visualizationBoxBottom.release(); + visualizationBoxTop.release(); + } +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/TargetOffsetPointEdge.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/TargetOffsetPointEdge.java new file mode 100644 index 000000000..6672b7f97 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/TargetOffsetPointEdge.java @@ -0,0 +1,9 @@ +package com.chameleonvision.common.vision.target; + +public enum TargetOffsetPointEdge { + Center, + Top, + Bottom, + Left, + Right +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/TargetOrientation.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/TargetOrientation.java new file mode 100644 index 000000000..cd7a9b085 --- /dev/null +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/TargetOrientation.java @@ -0,0 +1,6 @@ +package com.chameleonvision.common.vision.target; + +public enum TargetOrientation { + Portrait, + Landscape +} diff --git a/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/TrackedTarget.java b/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/TrackedTarget.java index c6df22343..c4bfdfce7 100644 --- a/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/TrackedTarget.java +++ b/chameleon-server/src/main/java/com/chameleonvision/common/vision/target/TrackedTarget.java @@ -2,16 +2,24 @@ package com.chameleonvision.common.vision.target; import com.chameleonvision.common.util.numbers.DoubleCouple; import com.chameleonvision.common.vision.opencv.Contour; +import com.chameleonvision.common.vision.opencv.Releasable; +import edu.wpi.first.wpilibj.geometry.Pose2d; import java.util.List; import org.apache.commons.math3.util.FastMath; +import org.opencv.core.Mat; +import org.opencv.core.MatOfPoint2f; import org.opencv.core.Point; import org.opencv.core.RotatedRect; // TODO: banks fix -public class TrackedTarget { - final Contour m_mainContour; +public class TrackedTarget implements Releasable { + public final Contour m_mainContour; List m_subContours; // can be empty + private MatOfPoint2f m_approximateBoundingPolygon; + + private List m_targetCorners; + private Point m_targetOffsetPoint; private Point m_robotOffsetPoint; @@ -19,12 +27,26 @@ public class TrackedTarget { private double m_yaw; private double m_area; + private Pose2d m_robotRelativePose; + + private Mat m_cameraRelativeTvec, m_cameraRelativeRvec; + public TrackedTarget(PotentialTarget origTarget, TargetCalculationParameters params) { this.m_mainContour = origTarget.m_mainContour; this.m_subContours = origTarget.m_subContours; calculateValues(params); } + /** + * Set the approximate bouding polygon. + * + * @param boundingPolygon List of points to copy. Not modified. + */ + public void setApproximateBoundingPolygon(MatOfPoint2f boundingPolygon) { + if (m_approximateBoundingPolygon == null) m_approximateBoundingPolygon = new MatOfPoint2f(); + boundingPolygon.copyTo(m_approximateBoundingPolygon); + } + public Point getTargetOffsetPoint() { return m_targetOffsetPoint; } @@ -49,8 +71,11 @@ public class TrackedTarget { return m_mainContour.getMinAreaRect(); } - private void calculateTargetOffsetPoint( - boolean isLandscape, TargetOffsetPointRegion offsetRegion) { + public MatOfPoint2f getApproximateBoundingPolygon() { + return m_approximateBoundingPolygon; + } + + private void calculateTargetOffsetPoint(boolean isLandscape, TargetOffsetPointEdge offsetRegion) { Point[] vertices = new Point[4]; var minAreaRect = getMinAreaRect(); @@ -145,7 +170,7 @@ public class TrackedTarget { public void calculateValues(TargetCalculationParameters params) { // this MUST happen in this exact order! - calculateTargetOffsetPoint(params.isLandscape, params.targetOffsetPointRegion); + calculateTargetOffsetPoint(params.isLandscape, params.targetOffsetPointEdge); calculateRobotOffsetPoint( m_targetOffsetPoint, params.cameraCenterPoint, @@ -158,10 +183,59 @@ public class TrackedTarget { calculateArea(params.imageArea); } + @Override + public void release() { + m_mainContour.release(); + for (var sc : m_subContours) { + sc.release(); + } + + if (m_cameraRelativeTvec != null) m_cameraRelativeTvec.release(); + if (m_cameraRelativeRvec != null) m_cameraRelativeRvec.release(); + } + + public void setCorners(List targetCorners) { + this.m_targetCorners = targetCorners; + } + + public List getTargetCorners() { + return m_targetCorners; + } + + public boolean hasSubContours() { + return !m_subContours.isEmpty(); + } + + public Pose2d getRobotRelativePose() { + return m_robotRelativePose; + } + + public void setRobotRelativePose(Pose2d robotRelativePose) { + this.m_robotRelativePose = robotRelativePose; + } + + public Mat getCameraRelativeTvec() { + return m_cameraRelativeTvec; + } + + public void setCameraRelativeTvec(Mat cameraRelativeTvec) { + if (this.m_cameraRelativeTvec == null) m_cameraRelativeTvec = new Mat(); + cameraRelativeTvec.copyTo(this.m_cameraRelativeTvec); + } + + public Mat getCameraRelativeRvec() { + return m_cameraRelativeRvec; + } + + public void setCameraRelativeRvec(Mat cameraRelativeRvec) { + if (this.m_cameraRelativeRvec == null) m_cameraRelativeRvec = new Mat(); + cameraRelativeRvec.copyTo(this.m_cameraRelativeRvec); + } + public static class TargetCalculationParameters { // TargetOffset calculation values final boolean isLandscape; - final TargetOffsetPointRegion targetOffsetPointRegion; + final TargetOffsetPointEdge targetOffsetPointEdge; // RobotOffset calculation values final Point userOffsetPoint; @@ -180,7 +254,7 @@ public class TrackedTarget { public TargetCalculationParameters( boolean isLandscape, - TargetOffsetPointRegion targetOffsetPointRegion, + TargetOffsetPointEdge targetOffsetPointEdge, Point userOffsetPoint, Point cameraCenterPoint, DoubleCouple offsetEquationValues, @@ -189,7 +263,7 @@ public class TrackedTarget { double verticalFocalLength, double imageArea) { this.isLandscape = isLandscape; - this.targetOffsetPointRegion = targetOffsetPointRegion; + this.targetOffsetPointEdge = targetOffsetPointEdge; this.userOffsetPoint = userOffsetPoint; this.cameraCenterPoint = cameraCenterPoint; this.offsetEquationValues = offsetEquationValues; @@ -199,24 +273,4 @@ public class TrackedTarget { this.imageArea = imageArea; } } - - // TODO: move these? also docs plox - public enum TargetOrientation { - Portrait, - Landscape - } - - public enum TargetOffsetPointRegion { - Center, - Top, - Bottom, - Left, - Right - } - - public enum RobotOffsetPointMode { - None, - Single, - Dual - } } diff --git a/chameleon-server/src/main/java/edu/wpi/first/wpilibj/geometry/Pose2d.java b/chameleon-server/src/main/java/edu/wpi/first/wpilibj/geometry/Pose2d.java index 24931c273..e1a05c0e1 100644 --- a/chameleon-server/src/main/java/edu/wpi/first/wpilibj/geometry/Pose2d.java +++ b/chameleon-server/src/main/java/edu/wpi/first/wpilibj/geometry/Pose2d.java @@ -1,5 +1,5 @@ /*----------------------------------------------------------------------------*/ -/* Copyright (c) 2019 FIRST. All Rights Reserved. */ +/* Copyright (c) 2019-2020 FIRST. All Rights Reserved. */ /* Open Source Software - may be modified and shared by FRC teams. The code */ /* must be accompanied by the FIRST BSD license file in the root directory of */ /* the project. */ @@ -7,22 +7,15 @@ package edu.wpi.first.wpilibj.geometry; -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.SerializerProvider; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import com.fasterxml.jackson.databind.deser.std.StdDeserializer; -import com.fasterxml.jackson.databind.ser.std.StdSerializer; -import java.io.IOException; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Objects; /** Represents a 2d pose containing translational and rotational elements. */ -@JsonSerialize(using = Pose2d.PoseSerializer.class) -@JsonDeserialize(using = Pose2d.PoseDeserializer.class) +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonAutoDetect(getterVisibility = JsonAutoDetect.Visibility.NONE) public class Pose2d { private final Translation2d m_translation; private final Rotation2d m_rotation; @@ -42,7 +35,10 @@ public class Pose2d { * @param translation The translational component of the pose. * @param rotation The rotational component of the pose. */ - public Pose2d(Translation2d translation, Rotation2d rotation) { + @JsonCreator + public Pose2d( + @JsonProperty(required = true, value = "translation") Translation2d translation, + @JsonProperty(required = true, value = "rotation") Rotation2d rotation) { m_translation = translation; m_rotation = rotation; } @@ -90,6 +86,7 @@ public class Pose2d { * * @return The translational component of the pose. */ + @JsonProperty public Translation2d getTranslation() { return m_translation; } @@ -99,6 +96,7 @@ public class Pose2d { * * @return The rotational component of the pose. */ + @JsonProperty public Rotation2d getRotation() { return m_rotation; } @@ -230,37 +228,4 @@ public class Pose2d { public int hashCode() { return Objects.hash(m_translation, m_rotation); } - - static class PoseSerializer extends StdSerializer { - PoseSerializer() { - super(Pose2d.class); - } - - @Override - public void serialize(Pose2d value, JsonGenerator jgen, SerializerProvider provider) - throws IOException, JsonProcessingException { - - jgen.writeStartObject(); - jgen.writeObjectField("translation", value.m_translation); - jgen.writeObjectField("rotation", value.m_rotation); - jgen.writeEndObject(); - } - } - - static class PoseDeserializer extends StdDeserializer { - PoseDeserializer() { - super(Pose2d.class); - } - - @Override - public Pose2d deserialize(JsonParser jp, DeserializationContext ctxt) - throws IOException, JsonProcessingException { - JsonNode node = jp.getCodec().readTree(jp); - - Translation2d translation = - jp.getCodec().treeToValue(node.get("translation"), Translation2d.class); - Rotation2d rotation = jp.getCodec().treeToValue(node.get("rotation"), Rotation2d.class); - return new Pose2d(translation, rotation); - } - } } diff --git a/chameleon-server/src/main/java/edu/wpi/first/wpilibj/geometry/Rotation2d.java b/chameleon-server/src/main/java/edu/wpi/first/wpilibj/geometry/Rotation2d.java index 73e211900..39a4450ff 100644 --- a/chameleon-server/src/main/java/edu/wpi/first/wpilibj/geometry/Rotation2d.java +++ b/chameleon-server/src/main/java/edu/wpi/first/wpilibj/geometry/Rotation2d.java @@ -1,5 +1,5 @@ /*----------------------------------------------------------------------------*/ -/* Copyright (c) 2019 FIRST. All Rights Reserved. */ +/* Copyright (c) 2019-2020 FIRST. All Rights Reserved. */ /* Open Source Software - may be modified and shared by FRC teams. The code */ /* must be accompanied by the FIRST BSD license file in the root directory of */ /* the project. */ @@ -7,22 +7,15 @@ package edu.wpi.first.wpilibj.geometry; -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.SerializerProvider; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import com.fasterxml.jackson.databind.deser.std.StdDeserializer; -import com.fasterxml.jackson.databind.ser.std.StdSerializer; -import java.io.IOException; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Objects; /** A rotation in a 2d coordinate frame represented a point on the unit circle (cosine and sine). */ -@JsonSerialize(using = Rotation2d.RotationSerializer.class) -@JsonDeserialize(using = Rotation2d.RotationDeserializer.class) +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonAutoDetect(getterVisibility = JsonAutoDetect.Visibility.NONE) public class Rotation2d { private final double m_value; private final double m_cos; @@ -40,7 +33,8 @@ public class Rotation2d { * * @param value The value of the angle in radians. */ - public Rotation2d(double value) { + @JsonCreator + public Rotation2d(@JsonProperty(required = true, value = "radians") double value) { m_value = value; m_cos = Math.cos(value); m_sin = Math.sin(value); @@ -133,11 +127,12 @@ public class Rotation2d { m_cos * other.m_cos - m_sin * other.m_sin, m_cos * other.m_sin + m_sin * other.m_cos); } - /* + /** * Returns the radian value of the rotation. * * @return The radian value of the rotation. */ + @JsonProperty public double getRadians() { return m_value; } @@ -201,34 +196,4 @@ public class Rotation2d { public int hashCode() { return Objects.hash(m_value); } - - static class RotationSerializer extends StdSerializer { - RotationSerializer() { - super(Rotation2d.class); - } - - @Override - public void serialize(Rotation2d value, JsonGenerator jgen, SerializerProvider provider) - throws IOException, JsonProcessingException { - - jgen.writeStartObject(); - jgen.writeNumberField("radians", value.m_value); - jgen.writeEndObject(); - } - } - - static class RotationDeserializer extends StdDeserializer { - RotationDeserializer() { - super(Rotation2d.class); - } - - @Override - public Rotation2d deserialize(JsonParser jp, DeserializationContext ctxt) - throws IOException, JsonProcessingException { - JsonNode node = jp.getCodec().readTree(jp); - double radians = node.get("radians").numberValue().doubleValue(); - - return new Rotation2d(radians); - } - } } diff --git a/chameleon-server/src/main/java/edu/wpi/first/wpilibj/geometry/Transform2d.java b/chameleon-server/src/main/java/edu/wpi/first/wpilibj/geometry/Transform2d.java index 5ab4fae01..9ec6ef2bf 100644 --- a/chameleon-server/src/main/java/edu/wpi/first/wpilibj/geometry/Transform2d.java +++ b/chameleon-server/src/main/java/edu/wpi/first/wpilibj/geometry/Transform2d.java @@ -1,5 +1,5 @@ /*----------------------------------------------------------------------------*/ -/* Copyright (c) 2019 FIRST. All Rights Reserved. */ +/* Copyright (c) 2019-2020 FIRST. All Rights Reserved. */ /* Open Source Software - may be modified and shared by FRC teams. The code */ /* must be accompanied by the FIRST BSD license file in the root directory of */ /* the project. */ @@ -77,6 +77,20 @@ public class Transform2d { return m_rotation; } + /** + * Invert the transformation. This is useful for undoing a transformation. + * + * @return The inverted transformation. + */ + public Transform2d inverse() { + // We are rotating the difference between the translations + // using a clockwise rotation matrix. This transforms the global + // delta into a local delta (relative to the initial pose). + return new Transform2d( + getTranslation().unaryMinus().rotateBy(getRotation().unaryMinus()), + getRotation().unaryMinus()); + } + @Override public String toString() { return String.format("Transform2d(%s, %s)", m_translation, m_rotation); diff --git a/chameleon-server/src/main/java/edu/wpi/first/wpilibj/geometry/Translation2d.java b/chameleon-server/src/main/java/edu/wpi/first/wpilibj/geometry/Translation2d.java index 2cdbba574..a67412d85 100644 --- a/chameleon-server/src/main/java/edu/wpi/first/wpilibj/geometry/Translation2d.java +++ b/chameleon-server/src/main/java/edu/wpi/first/wpilibj/geometry/Translation2d.java @@ -1,5 +1,5 @@ /*----------------------------------------------------------------------------*/ -/* Copyright (c) 2019 FIRST. All Rights Reserved. */ +/* Copyright (c) 2019-2020 FIRST. All Rights Reserved. */ /* Open Source Software - may be modified and shared by FRC teams. The code */ /* must be accompanied by the FIRST BSD license file in the root directory of */ /* the project. */ @@ -7,17 +7,10 @@ package edu.wpi.first.wpilibj.geometry; -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.SerializerProvider; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import com.fasterxml.jackson.databind.deser.std.StdDeserializer; -import com.fasterxml.jackson.databind.ser.std.StdSerializer; -import java.io.IOException; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Objects; /** @@ -27,9 +20,9 @@ import java.util.Objects; * the origin, facing toward the X direction, moving forward increases the X, whereas moving to the * left increases the Y. */ -@JsonSerialize(using = Translation2d.TranslationSerializer.class) -@JsonDeserialize(using = Translation2d.TranslationDeserializer.class) @SuppressWarnings({"ParameterName", "MemberName"}) +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonAutoDetect(getterVisibility = JsonAutoDetect.Visibility.NONE) public class Translation2d { private final double m_x; private final double m_y; @@ -45,7 +38,10 @@ public class Translation2d { * @param x The x component of the translation. * @param y The y component of the translation. */ - public Translation2d(double x, double y) { + @JsonCreator + public Translation2d( + @JsonProperty(required = true, value = "x") double x, + @JsonProperty(required = true, value = "y") double y) { m_x = x; m_y = y; } @@ -68,6 +64,7 @@ public class Translation2d { * * @return The x component of the translation. */ + @JsonProperty public double getX() { return m_x; } @@ -77,6 +74,7 @@ public class Translation2d { * * @return The y component of the translation. */ + @JsonProperty public double getY() { return m_y; } @@ -170,10 +168,6 @@ public class Translation2d { return String.format("Translation2d(X: %.2f, Y: %.2f)", m_x, m_y); } - public static Translation2d fromRotation2d(Rotation2d rotation) { - return new Translation2d(rotation.getCos(), rotation.getSin()); - } - /** * Checks equality between this Translation2d and another object. * @@ -193,36 +187,4 @@ public class Translation2d { public int hashCode() { return Objects.hash(m_x, m_y); } - - static class TranslationSerializer extends StdSerializer { - TranslationSerializer() { - super(Translation2d.class); - } - - @Override - public void serialize(Translation2d value, JsonGenerator jgen, SerializerProvider provider) - throws IOException, JsonProcessingException { - - jgen.writeStartObject(); - jgen.writeNumberField("x", value.m_x); - jgen.writeNumberField("y", value.m_y); - jgen.writeEndObject(); - } - } - - static class TranslationDeserializer extends StdDeserializer { - TranslationDeserializer() { - super(Translation2d.class); - } - - @Override - public Translation2d deserialize(JsonParser jp, DeserializationContext ctxt) - throws IOException, JsonProcessingException { - JsonNode node = jp.getCodec().readTree(jp); - double xval = node.get("x").numberValue().doubleValue(); - double yval = node.get("y").numberValue().doubleValue(); - - return new Translation2d(xval, yval); - } - } } diff --git a/chameleon-server/src/main/resources/log4j.properties b/chameleon-server/src/main/resources/log4j.properties new file mode 100644 index 000000000..636e70811 --- /dev/null +++ b/chameleon-server/src/main/resources/log4j.properties @@ -0,0 +1 @@ +log4j.rootLogger=INFO, STDOUT \ No newline at end of file diff --git a/chameleon-server/src/test/java/com/chameleonvision/common/vision/pipeline/ReflectivePipelineTest.java b/chameleon-server/src/test/java/com/chameleonvision/common/vision/pipeline/ReflectivePipelineTest.java new file mode 100644 index 000000000..08ad99c8b --- /dev/null +++ b/chameleon-server/src/test/java/com/chameleonvision/common/vision/pipeline/ReflectivePipelineTest.java @@ -0,0 +1,125 @@ +package com.chameleonvision.common.vision.pipeline; + +import com.chameleonvision.common.util.TestUtils; +import com.chameleonvision.common.vision.frame.Frame; +import com.chameleonvision.common.vision.frame.provider.FileFrameProvider; +import com.chameleonvision.common.vision.opencv.CVMat; +import com.chameleonvision.common.vision.opencv.ContourGroupingMode; +import com.chameleonvision.common.vision.opencv.ContourIntersectionDirection; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +public class ReflectivePipelineTest { + + public static void setLoggingLevel(ch.qos.logback.classic.Level level) { + ch.qos.logback.classic.Logger root = + (ch.qos.logback.classic.Logger) + org.slf4j.LoggerFactory.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME); + root.setLevel(level); + } + + @Test + public void testDebug() { + var logger = LoggerFactory.getLogger(ReflectivePipelineTest.class); + setLoggingLevel(ch.qos.logback.classic.Level.WARN); + logger.warn(String.valueOf(logger.isDebugEnabled())); + logger.info("hi"); + logger.debug("debug"); + } + + @Test + public void test2019() { + TestUtils.loadLibraries(); + var pipeline = new ReflectivePipeline(); + + var settings = new ReflectivePipelineSettings(); + settings.hsvHue.set(60, 100); + settings.hsvSaturation.set(100, 255); + settings.hsvValue.set(190, 255); + settings.outputShowThresholded = true; + settings.outputShowMultipleTargets = true; + settings.contourGroupingMode = ContourGroupingMode.Dual; + settings.contourIntersection = ContourIntersectionDirection.Up; + + var frameProvider = + new FileFrameProvider( + TestUtils.getWPIImagePath(TestUtils.WPI2019Image.kCargoStraightDark72in_HighRes), + TestUtils.WPI2019Image.FOV); + + TestUtils.showImage(frameProvider.getFrame().image.getMat(), "Pipeline input", 1); + + CVPipelineResult pipelineResult; + + pipelineResult = pipeline.run(frameProvider.getFrame(), settings); + printTestResults(pipelineResult); + + Assertions.assertTrue(pipelineResult.hasTargets()); + Assertions.assertEquals(2, pipelineResult.targets.size()); + + TestUtils.showImage(pipelineResult.outputFrame.image.getMat(), "Pipeline output"); + } + + @Test + public void test2020() { + TestUtils.loadLibraries(); + var pipeline = new ReflectivePipeline(); + + var settings = new ReflectivePipelineSettings(); + settings.hsvHue.set(60, 100); + settings.hsvSaturation.set(200, 255); + settings.hsvValue.set(200, 255); + settings.outputShowThresholded = true; + + var frameProvider = + new FileFrameProvider( + TestUtils.getWPIImagePath(TestUtils.WPI2020Image.kBlueGoal_108in_Center), + TestUtils.WPI2020Image.FOV); + + CVPipelineResult pipelineResult = pipeline.run(frameProvider.getFrame(), settings); + printTestResults(pipelineResult); + + TestUtils.showImage(pipelineResult.outputFrame.image.getMat(), "Pipeline output"); + } + + private static void continuouslyRunPipeline(Frame frame, ReflectivePipelineSettings settings) { + var pipeline = new ReflectivePipeline(); + + while (true) { + CVPipelineResult pipelineResult = pipeline.run(frame, settings); + printTestResults(pipelineResult); + int preRelease = CVMat.getMatCount(); + pipelineResult.release(); + int postRelease = CVMat.getMatCount(); + + System.out.printf("Pre: %d, Post: %d\n", preRelease, postRelease); + } + } + + // used to run VisualVM for profiling. It won't run on unit tests. + public static void main(String[] args) { + TestUtils.loadLibraries(); + var frameProvider = + new FileFrameProvider( + TestUtils.getWPIImagePath(TestUtils.WPI2019Image.kCargoStraightDark72in_HighRes), + TestUtils.WPI2019Image.FOV); + + var settings = new ReflectivePipelineSettings(); + settings.hsvHue.set(60, 100); + settings.hsvSaturation.set(100, 255); + settings.hsvValue.set(190, 255); + settings.outputShowThresholded = true; + settings.outputShowMultipleTargets = true; + settings.contourGroupingMode = ContourGroupingMode.Dual; + settings.contourIntersection = ContourIntersectionDirection.Up; + + continuouslyRunPipeline(frameProvider.getFrame(), settings); + } + + private static void printTestResults(CVPipelineResult pipelineResult) { + double fps = 1000 / pipelineResult.getLatencyMillis(); + System.out.print( + "Pipeline ran in " + pipelineResult.getLatencyMillis() + "ms (" + fps + " fps), "); + System.out.println("Found " + pipelineResult.targets.size() + " valid targets"); + } +} diff --git a/chameleon-server/src/test/java/com/chameleonvision/common/vision/pipeline/SolvePNPTest.java b/chameleon-server/src/test/java/com/chameleonvision/common/vision/pipeline/SolvePNPTest.java new file mode 100644 index 000000000..7b63cbed0 --- /dev/null +++ b/chameleon-server/src/test/java/com/chameleonvision/common/vision/pipeline/SolvePNPTest.java @@ -0,0 +1,197 @@ +package com.chameleonvision.common.vision.pipeline; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.chameleonvision.common.calibration.CameraCalibrationCoefficients; +import com.chameleonvision.common.util.TestUtils; +import com.chameleonvision.common.vision.frame.Frame; +import com.chameleonvision.common.vision.frame.provider.FileFrameProvider; +import com.chameleonvision.common.vision.opencv.CVMat; +import com.chameleonvision.common.vision.opencv.ContourGroupingMode; +import com.chameleonvision.common.vision.opencv.ContourIntersectionDirection; +import com.chameleonvision.common.vision.target.TargetModel; +import com.chameleonvision.common.vision.target.TrackedTarget; +import com.fasterxml.jackson.databind.ObjectMapper; +import edu.wpi.first.wpilibj.geometry.Rotation2d; +import java.io.IOException; +import java.nio.file.Path; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +public class SolvePNPTest { + + @Test + public void meme() throws IOException { + TestUtils.loadLibraries(); + + var lowres = (Path.of(TestUtils.getCalibrationPath().toString(), "lifecamcal.json").toFile()); + var cal1 = new ObjectMapper().readValue(lowres, CameraCalibrationCoefficients.class); + + var highres = (Path.of(TestUtils.getCalibrationPath().toString(), "lifecamcal2.json").toFile()); + var cal2 = new ObjectMapper().readValue(highres, CameraCalibrationCoefficients.class); + } + + private CameraCalibrationCoefficients get640p() { + try { + var cameraCalibration = + new ObjectMapper() + .readValue( + (Path.of(TestUtils.getCalibrationPath().toString(), "lifecam640p.json").toFile()), + CameraCalibrationCoefficients.class); + + assertEquals(3, cameraCalibration.cameraIntrinsics.rows); + assertEquals(3, cameraCalibration.cameraIntrinsics.cols); + assertEquals(1, cameraCalibration.cameraExtrinsics.rows); + assertEquals(5, cameraCalibration.cameraExtrinsics.cols); + assertEquals(3, cameraCalibration.cameraIntrinsics.getAsMat().rows()); + assertEquals(3, cameraCalibration.cameraIntrinsics.getAsMat().cols()); + assertEquals(1, cameraCalibration.cameraExtrinsics.getAsMat().rows()); + assertEquals(5, cameraCalibration.cameraExtrinsics.getAsMat().cols()); + assertEquals(3, cameraCalibration.cameraIntrinsics.getAsMatOfDouble().rows()); + assertEquals(3, cameraCalibration.cameraIntrinsics.getAsMatOfDouble().cols()); + assertEquals(1, cameraCalibration.cameraExtrinsics.getAsMatOfDouble().rows()); + assertEquals(5, cameraCalibration.cameraExtrinsics.getAsMatOfDouble().cols()); + assertEquals(3, cameraCalibration.getCameraIntrinsicsMat().rows()); + assertEquals(3, cameraCalibration.getCameraIntrinsicsMat().cols()); + assertEquals(1, cameraCalibration.getCameraExtrinsicsMat().rows()); + assertEquals(5, cameraCalibration.getCameraExtrinsicsMat().cols()); + + return cameraCalibration; + } catch (IOException e) { + e.printStackTrace(); + return null; + } + } + + @Test + public void test2019() { + TestUtils.loadLibraries(); + var pipeline = new ReflectivePipeline(); + + var settings = new ReflectivePipelineSettings(); + settings.hsvHue.set(60, 100); + settings.hsvSaturation.set(100, 255); + settings.hsvValue.set(190, 255); + settings.outputShowThresholded = true; + settings.outputShowMultipleTargets = true; + settings.solvePNPEnabled = true; + settings.contourGroupingMode = ContourGroupingMode.Dual; + settings.contourIntersection = ContourIntersectionDirection.Up; + settings.cornerDetectionUseConvexHulls = true; + + var frameProvider = + new FileFrameProvider( + TestUtils.getWPIImagePath(TestUtils.WPI2019Image.kCargoStraightDark48in), + TestUtils.WPI2019Image.FOV); + + CVPipelineResult pipelineResult; + + pipelineResult = pipeline.run(frameProvider.getFrame(), settings); + + TestUtils.showImage(pipelineResult.outputFrame.image.getMat(), "Pipeline output", 1000 * 90); + } + + @Test + public void test2020() { + TestUtils.loadLibraries(); + var pipeline = new ReflectivePipeline(); + + var settings = new ReflectivePipelineSettings(); + settings.hsvHue.set(60, 100); + settings.hsvSaturation.set(100, 255); + settings.hsvValue.set(60, 255); + settings.outputShowThresholded = true; + settings.solvePNPEnabled = true; + settings.cornerDetectionAccuracyPercentage = 4; + settings.cornerDetectionUseConvexHulls = true; + settings.cameraCalibration = get640p(); + settings.targetModel = TargetModel.get2020Target(36); + settings.cameraPitch = Rotation2d.fromDegrees(0.0); + + assertNotNull(settings.cameraCalibration); + assertEquals(3, settings.cameraCalibration.cameraIntrinsics.rows); + assertEquals(3, settings.cameraCalibration.cameraIntrinsics.cols); + assertEquals(1, settings.cameraCalibration.cameraExtrinsics.rows); + assertEquals(5, settings.cameraCalibration.cameraExtrinsics.cols); + + assertEquals(3, settings.cameraCalibration.cameraIntrinsics.getAsMat().rows()); + assertEquals(3, settings.cameraCalibration.cameraIntrinsics.getAsMat().cols()); + assertEquals(1, settings.cameraCalibration.cameraExtrinsics.getAsMat().rows()); + assertEquals(5, settings.cameraCalibration.cameraExtrinsics.getAsMat().cols()); + + assertEquals(3, settings.cameraCalibration.cameraIntrinsics.getAsMatOfDouble().rows()); + assertEquals(3, settings.cameraCalibration.cameraIntrinsics.getAsMatOfDouble().cols()); + assertEquals(1, settings.cameraCalibration.cameraExtrinsics.getAsMatOfDouble().rows()); + assertEquals(5, settings.cameraCalibration.cameraExtrinsics.getAsMatOfDouble().cols()); + + assertEquals(3, settings.cameraCalibration.getCameraIntrinsicsMat().rows()); + assertEquals(3, settings.cameraCalibration.getCameraIntrinsicsMat().cols()); + assertEquals(1, settings.cameraCalibration.getCameraExtrinsicsMat().rows()); + assertEquals(5, settings.cameraCalibration.getCameraExtrinsicsMat().cols()); + + var frameProvider = + new FileFrameProvider( + TestUtils.getWPIImagePath(TestUtils.WPI2020Image.kBlueGoal_224in_Left), + TestUtils.WPI2020Image.FOV); + + // TestUtils.showImage(frameProvider.getFrame().image.getMat(), "Pipeline output", + // 999999); + + CVPipelineResult pipelineResult = pipeline.run(frameProvider.getFrame(), settings); + printTestResults(pipelineResult); + + var pose = pipelineResult.targets.get(0).getRobotRelativePose(); + // assertEquals(180, pose.getTranslation().getX(), 20); + // assertEquals(0, pose.getTranslation().getY(), 20); + // assertEquals(0, pose.getRotation().getDegrees(), 5); + + TestUtils.showImage(pipelineResult.outputFrame.image.getMat(), "Pipeline output", 999999); + } + + private static void continuouslyRunPipeline(Frame frame, ReflectivePipelineSettings settings) { + var pipeline = new ReflectivePipeline(); + + while (true) { + CVPipelineResult pipelineResult = pipeline.run(frame, settings); + printTestResults(pipelineResult); + int preRelease = CVMat.getMatCount(); + pipelineResult.release(); + int postRelease = CVMat.getMatCount(); + + System.out.printf("Pre: %d, Post: %d\n", preRelease, postRelease); + } + } + + // used to run VisualVM for profiling. It won't run on unit tests. + public static void main(String[] args) { + TestUtils.loadLibraries(); + var frameProvider = + new FileFrameProvider( + TestUtils.getWPIImagePath(TestUtils.WPI2019Image.kCargoStraightDark72in_HighRes), + TestUtils.WPI2019Image.FOV); + + var settings = new ReflectivePipelineSettings(); + settings.hsvHue.set(60, 100); + settings.hsvSaturation.set(100, 255); + settings.hsvValue.set(190, 255); + settings.outputShowThresholded = true; + settings.outputShowMultipleTargets = true; + settings.contourGroupingMode = ContourGroupingMode.Dual; + settings.contourIntersection = ContourIntersectionDirection.Up; + + continuouslyRunPipeline(frameProvider.getFrame(), settings); + } + + private static void printTestResults(CVPipelineResult pipelineResult) { + double fps = 1000 / pipelineResult.getLatencyMillis(); + System.out.println( + "Pipeline ran in " + pipelineResult.getLatencyMillis() + "ms (" + fps + " " + "fps)"); + System.out.println("Found " + pipelineResult.targets.size() + " valid targets"); + System.out.println( + "Found targets at " + + pipelineResult.targets.stream() + .map(TrackedTarget::getRobotRelativePose) + .collect(Collectors.toList())); + } +} diff --git a/chameleon-server/src/test/resources/calibration/lifecam320p.json b/chameleon-server/src/test/resources/calibration/lifecam320p.json new file mode 100644 index 000000000..349c450c8 --- /dev/null +++ b/chameleon-server/src/test/resources/calibration/lifecam320p.json @@ -0,0 +1,34 @@ +{ + "resolution": { + "width": 320.0, + "height": 240.0 + }, + "cameraIntrinsics": { + "rows": 3, + "cols": 3, + "type": 6, + "data": [ + 353.74653217742724, + 0.0, + 163.55407989211918, + 0.0, + 340.77624878700817, + 119.8945718300403, + 0.0, + 0.0, + 1.0 + ] + }, + "cameraExtrinsics": { + "rows": 1, + "cols": 5, + "type": 6, + "data": [ + 0.10322037759535845, + -0.2890556437050186, + 0.00406400648501475, + 2.5573586808275763E-4, + -1.462385758978924 + ] + } +} \ No newline at end of file diff --git a/chameleon-server/src/test/resources/calibration/lifecam640p.json b/chameleon-server/src/test/resources/calibration/lifecam640p.json new file mode 100644 index 000000000..fff629bf6 --- /dev/null +++ b/chameleon-server/src/test/resources/calibration/lifecam640p.json @@ -0,0 +1,34 @@ +{ + "resolution": { + "width": 640.0, + "height": 480.0 + }, + "cameraIntrinsics": { + "rows": 3, + "cols": 3, + "type": 6, + "data": [ + 699.3778103158814, + 0.0, + 345.6059345433618, + 0.0, + 677.7161226393544, + 207.12741326228522, + 0.0, + 0.0, + 1.0 + ] + }, + "cameraExtrinsics": { + "rows": 1, + "cols": 5, + "type": 6, + "data": [ + 0.14382207979312617, + -0.9851192814987014, + -0.018168751047242335, + 0.011034504043795105, + 1.9833437176538498 + ] + } +} \ No newline at end of file diff --git a/testimages/2019/CargoAngledDark48in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/CargoAngledDark48in.jpg similarity index 100% rename from testimages/2019/CargoAngledDark48in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/CargoAngledDark48in.jpg diff --git a/testimages/2019/CargoSideStraightDark36in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/CargoSideStraightDark36in.jpg similarity index 100% rename from testimages/2019/CargoSideStraightDark36in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/CargoSideStraightDark36in.jpg diff --git a/testimages/2019/CargoSideStraightDark60in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/CargoSideStraightDark60in.jpg similarity index 100% rename from testimages/2019/CargoSideStraightDark60in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/CargoSideStraightDark60in.jpg diff --git a/testimages/2019/CargoSideStraightDark72in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/CargoSideStraightDark72in.jpg similarity index 100% rename from testimages/2019/CargoSideStraightDark72in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/CargoSideStraightDark72in.jpg diff --git a/testimages/2019/CargoSideStraightPanelDark36in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/CargoSideStraightPanelDark36in.jpg similarity index 100% rename from testimages/2019/CargoSideStraightPanelDark36in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/CargoSideStraightPanelDark36in.jpg diff --git a/testimages/2019/CargoStraightDark19in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/CargoStraightDark19in.jpg similarity index 100% rename from testimages/2019/CargoStraightDark19in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/CargoStraightDark19in.jpg diff --git a/testimages/2019/CargoStraightDark24in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/CargoStraightDark24in.jpg similarity index 100% rename from testimages/2019/CargoStraightDark24in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/CargoStraightDark24in.jpg diff --git a/testimages/2019/CargoStraightDark48in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/CargoStraightDark48in.jpg similarity index 100% rename from testimages/2019/CargoStraightDark48in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/CargoStraightDark48in.jpg diff --git a/testimages/2019/CargoStraightDark72in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/CargoStraightDark72in.jpg similarity index 100% rename from testimages/2019/CargoStraightDark72in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/CargoStraightDark72in.jpg diff --git a/chameleon-server/src/test/resources/testimages/2019/WPI/CargoStraightDark72in_HighRes.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/CargoStraightDark72in_HighRes.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e56d05291453506a9a0aa00b31f71eecc47376e2 GIT binary patch literal 244754 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 Wh+BM3AFR=at!IcZj$r}+|C<0)3G}M~ literal 0 HcmV?d00001 diff --git a/testimages/2019/CargoStraightDark90in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/CargoStraightDark90in.jpg similarity index 100% rename from testimages/2019/CargoStraightDark90in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/CargoStraightDark90in.jpg diff --git a/testimages/2019/LoadingAngle36in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/LoadingAngle36in.jpg similarity index 100% rename from testimages/2019/LoadingAngle36in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/LoadingAngle36in.jpg diff --git a/testimages/2019/LoadingAngleDark36in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/LoadingAngleDark36in.jpg similarity index 100% rename from testimages/2019/LoadingAngleDark36in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/LoadingAngleDark36in.jpg diff --git a/testimages/2019/LoadingAngleDark60in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/LoadingAngleDark60in.jpg similarity index 100% rename from testimages/2019/LoadingAngleDark60in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/LoadingAngleDark60in.jpg diff --git a/testimages/2019/LoadingAngleDark96in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/LoadingAngleDark96in.jpg similarity index 100% rename from testimages/2019/LoadingAngleDark96in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/LoadingAngleDark96in.jpg diff --git a/testimages/2019/LoadingStraight108in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraight108in.jpg similarity index 100% rename from testimages/2019/LoadingStraight108in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraight108in.jpg diff --git a/testimages/2019/LoadingStraight36in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraight36in.jpg similarity index 100% rename from testimages/2019/LoadingStraight36in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraight36in.jpg diff --git a/testimages/2019/LoadingStraightDark108in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraightDark108in.jpg similarity index 100% rename from testimages/2019/LoadingStraightDark108in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraightDark108in.jpg diff --git a/testimages/2019/LoadingStraightDark10in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraightDark10in.jpg similarity index 100% rename from testimages/2019/LoadingStraightDark10in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraightDark10in.jpg diff --git a/testimages/2019/LoadingStraightDark13in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraightDark13in.jpg similarity index 100% rename from testimages/2019/LoadingStraightDark13in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraightDark13in.jpg diff --git a/testimages/2019/LoadingStraightDark21in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraightDark21in.jpg similarity index 100% rename from testimages/2019/LoadingStraightDark21in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraightDark21in.jpg diff --git a/testimages/2019/LoadingStraightDark36in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraightDark36in.jpg similarity index 100% rename from testimages/2019/LoadingStraightDark36in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraightDark36in.jpg diff --git a/testimages/2019/LoadingStraightDark48in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraightDark48in.jpg similarity index 100% rename from testimages/2019/LoadingStraightDark48in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraightDark48in.jpg diff --git a/testimages/2019/LoadingStraightDark60in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraightDark60in.jpg similarity index 100% rename from testimages/2019/LoadingStraightDark60in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraightDark60in.jpg diff --git a/testimages/2019/LoadingStraightDark84in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraightDark84in.jpg similarity index 100% rename from testimages/2019/LoadingStraightDark84in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraightDark84in.jpg diff --git a/testimages/2019/LoadingStraightDark9in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraightDark9in.jpg similarity index 100% rename from testimages/2019/LoadingStraightDark9in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/LoadingStraightDark9in.jpg diff --git a/testimages/2019/RocketBallStraightDark19in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/RocketBallStraightDark19in.jpg similarity index 100% rename from testimages/2019/RocketBallStraightDark19in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/RocketBallStraightDark19in.jpg diff --git a/testimages/2019/RocketBallStraightDark24in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/RocketBallStraightDark24in.jpg similarity index 100% rename from testimages/2019/RocketBallStraightDark24in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/RocketBallStraightDark24in.jpg diff --git a/testimages/2019/RocketBallStraightDark29in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/RocketBallStraightDark29in.jpg similarity index 100% rename from testimages/2019/RocketBallStraightDark29in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/RocketBallStraightDark29in.jpg diff --git a/testimages/2019/RocketBallStraightDark48in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/RocketBallStraightDark48in.jpg similarity index 100% rename from testimages/2019/RocketBallStraightDark48in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/RocketBallStraightDark48in.jpg diff --git a/testimages/2019/RocketPanelAngleDark48in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelAngleDark48in.jpg similarity index 100% rename from testimages/2019/RocketPanelAngleDark48in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelAngleDark48in.jpg diff --git a/testimages/2019/RocketPanelAngleDark60in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelAngleDark60in.jpg similarity index 100% rename from testimages/2019/RocketPanelAngleDark60in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelAngleDark60in.jpg diff --git a/testimages/2019/RocketPanelAngleDark84in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelAngleDark84in.jpg similarity index 100% rename from testimages/2019/RocketPanelAngleDark84in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelAngleDark84in.jpg diff --git a/testimages/2019/RocketPanelStraight48in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraight48in.jpg similarity index 100% rename from testimages/2019/RocketPanelStraight48in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraight48in.jpg diff --git a/testimages/2019/RocketPanelStraight84in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraight84in.jpg similarity index 100% rename from testimages/2019/RocketPanelStraight84in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraight84in.jpg diff --git a/testimages/2019/RocketPanelStraightDark12in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraightDark12in.jpg similarity index 100% rename from testimages/2019/RocketPanelStraightDark12in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraightDark12in.jpg diff --git a/testimages/2019/RocketPanelStraightDark16in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraightDark16in.jpg similarity index 100% rename from testimages/2019/RocketPanelStraightDark16in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraightDark16in.jpg diff --git a/testimages/2019/RocketPanelStraightDark24in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraightDark24in.jpg similarity index 100% rename from testimages/2019/RocketPanelStraightDark24in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraightDark24in.jpg diff --git a/testimages/2019/RocketPanelStraightDark36in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraightDark36in.jpg similarity index 100% rename from testimages/2019/RocketPanelStraightDark36in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraightDark36in.jpg diff --git a/testimages/2019/RocketPanelStraightDark48in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraightDark48in.jpg similarity index 100% rename from testimages/2019/RocketPanelStraightDark48in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraightDark48in.jpg diff --git a/testimages/2019/RocketPanelStraightDark60in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraightDark60in.jpg similarity index 100% rename from testimages/2019/RocketPanelStraightDark60in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraightDark60in.jpg diff --git a/testimages/2019/RocketPanelStraightDark72in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraightDark72in.jpg similarity index 100% rename from testimages/2019/RocketPanelStraightDark72in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraightDark72in.jpg diff --git a/testimages/2019/RocketPanelStraightDark96in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraightDark96in.jpg similarity index 100% rename from testimages/2019/RocketPanelStraightDark96in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/RocketPanelStraightDark96in.jpg diff --git a/testimages/2019/RocketStraightDark96in.jpg b/chameleon-server/src/test/resources/testimages/2019/WPI/RocketStraightDark96in.jpg similarity index 100% rename from testimages/2019/RocketStraightDark96in.jpg rename to chameleon-server/src/test/resources/testimages/2019/WPI/RocketStraightDark96in.jpg diff --git a/testimages/2019/info.txt b/chameleon-server/src/test/resources/testimages/2019/WPI/info.txt similarity index 100% rename from testimages/2019/info.txt rename to chameleon-server/src/test/resources/testimages/2019/WPI/info.txt diff --git a/testimages/2020/BlueGoal-060in-Center.jpg b/chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-060in-Center.jpg similarity index 100% rename from testimages/2020/BlueGoal-060in-Center.jpg rename to chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-060in-Center.jpg diff --git a/testimages/2020/BlueGoal-084in-Center.jpg b/chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-084in-Center.jpg similarity index 100% rename from testimages/2020/BlueGoal-084in-Center.jpg rename to chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-084in-Center.jpg diff --git a/testimages/2020/BlueGoal-108in-Center.jpg b/chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-108in-Center.jpg similarity index 100% rename from testimages/2020/BlueGoal-108in-Center.jpg rename to chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-108in-Center.jpg diff --git a/testimages/2020/BlueGoal-132in-Center.jpg b/chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-132in-Center.jpg similarity index 100% rename from testimages/2020/BlueGoal-132in-Center.jpg rename to chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-132in-Center.jpg diff --git a/testimages/2020/BlueGoal-156in-Center.jpg b/chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-156in-Center.jpg similarity index 100% rename from testimages/2020/BlueGoal-156in-Center.jpg rename to chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-156in-Center.jpg diff --git a/testimages/2020/BlueGoal-156in-Left.jpg b/chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-156in-Left.jpg similarity index 100% rename from testimages/2020/BlueGoal-156in-Left.jpg rename to chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-156in-Left.jpg diff --git a/testimages/2020/BlueGoal-180in-Center.jpg b/chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-180in-Center.jpg similarity index 100% rename from testimages/2020/BlueGoal-180in-Center.jpg rename to chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-180in-Center.jpg diff --git a/testimages/2020/BlueGoal-224in-Center.jpg b/chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-224in-Left.jpg similarity index 100% rename from testimages/2020/BlueGoal-224in-Center.jpg rename to chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-224in-Left.jpg diff --git a/testimages/2020/BlueGoal-228in-ProtectedZone.jpg b/chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-228in-ProtectedZone.jpg similarity index 100% rename from testimages/2020/BlueGoal-228in-ProtectedZone.jpg rename to chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-228in-ProtectedZone.jpg diff --git a/testimages/2020/BlueGoal-330in-ProtectedZone.jpg b/chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-330in-ProtectedZone.jpg similarity index 100% rename from testimages/2020/BlueGoal-330in-ProtectedZone.jpg rename to chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-330in-ProtectedZone.jpg diff --git a/testimages/2020/BlueGoal-Far-ProtectedZone.jpg b/chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-Far-ProtectedZone.jpg similarity index 100% rename from testimages/2020/BlueGoal-Far-ProtectedZone.jpg rename to chameleon-server/src/test/resources/testimages/2020/WPI/BlueGoal-Far-ProtectedZone.jpg diff --git a/testimages/2020/RedLoading-016in-Down.jpg b/chameleon-server/src/test/resources/testimages/2020/WPI/RedLoading-016in-Down.jpg similarity index 100% rename from testimages/2020/RedLoading-016in-Down.jpg rename to chameleon-server/src/test/resources/testimages/2020/WPI/RedLoading-016in-Down.jpg diff --git a/testimages/2020/RedLoading-030in-Down.jpg b/chameleon-server/src/test/resources/testimages/2020/WPI/RedLoading-030in-Down.jpg similarity index 100% rename from testimages/2020/RedLoading-030in-Down.jpg rename to chameleon-server/src/test/resources/testimages/2020/WPI/RedLoading-030in-Down.jpg diff --git a/testimages/2020/RedLoading-048in-Down.jpg b/chameleon-server/src/test/resources/testimages/2020/WPI/RedLoading-048in-Down.jpg similarity index 100% rename from testimages/2020/RedLoading-048in-Down.jpg rename to chameleon-server/src/test/resources/testimages/2020/WPI/RedLoading-048in-Down.jpg diff --git a/testimages/2020/RedLoading-048in.jpg b/chameleon-server/src/test/resources/testimages/2020/WPI/RedLoading-048in.jpg similarity index 100% rename from testimages/2020/RedLoading-048in.jpg rename to chameleon-server/src/test/resources/testimages/2020/WPI/RedLoading-048in.jpg diff --git a/testimages/2020/RedLoading-060in.jpg b/chameleon-server/src/test/resources/testimages/2020/WPI/RedLoading-060in.jpg similarity index 100% rename from testimages/2020/RedLoading-060in.jpg rename to chameleon-server/src/test/resources/testimages/2020/WPI/RedLoading-060in.jpg diff --git a/testimages/2020/RedLoading-084in.jpg b/chameleon-server/src/test/resources/testimages/2020/WPI/RedLoading-084in.jpg similarity index 100% rename from testimages/2020/RedLoading-084in.jpg rename to chameleon-server/src/test/resources/testimages/2020/WPI/RedLoading-084in.jpg diff --git a/testimages/2020/RedLoading-108in.jpg b/chameleon-server/src/test/resources/testimages/2020/WPI/RedLoading-108in.jpg similarity index 100% rename from testimages/2020/RedLoading-108in.jpg rename to chameleon-server/src/test/resources/testimages/2020/WPI/RedLoading-108in.jpg diff --git a/chameleon-server/src/test/resources/testimages/2020/WPI/info.txt b/chameleon-server/src/test/resources/testimages/2020/WPI/info.txt new file mode 100644 index 000000000..cc4714e73 --- /dev/null +++ b/chameleon-server/src/test/resources/testimages/2020/WPI/info.txt @@ -0,0 +1,3 @@ +Taken on Microsoft LifeCam HD-3000 +FOV = 68.5 +Credit to WPILib for these images. \ No newline at end of file diff --git a/testimages/2020/image.png b/testimages/2020/image.png deleted file mode 100644 index 0e137e860f9cadc49930c1315b2175d2c768319f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19136 zcmeHvdpMNq_xD4h)F!nhC5pC^qL6c@+Guyc?l2@VHX_H7(-1SG)RvHJNerf)P>z)t z=TXivgczqW&Vz|@9y4QR-h0OWet+-#_xsO#UGMI?YNpI{KkHuWvp(yy*8NOTzgwRF zNpi0w1VKMt_|5bx1Z@UyMZawk2QTetiZuisfG(Jxy&jzUgJPTDLQ7U!r5!7S!^xeo zX*Yh`0d3tWW*~YtyzXVEuW7TSfzH(B(+aZ%J7TSpI_2P z+T=;TrS}_pF0EX(hsSAJrsw7PK$Psy5d=x@r8>=zM~#yBYUACobS7Fwc_**{T5CP3 zD0qeTSS<)&Yl5Fh2;VeZGZwy{NFNmuya`|USz7oHG1l~ zaq(}C06zGaFCge&?u7oWBmdS^fjanC96Z z*-UM;;3q?3(AwoAK>dYVU64Jzd6@emoV5Sva0sGV3e;rS#Xon4(-XoEw;#vuhoJ3O zh1dU80L6Goya>NLV=z&;2?{?e5S+u;I_1LYIhhrB{*~K~EII@^KHIQzW>l0{UIys7$KU$(C~7TiQ`b1Ts+(uF-X!a!`4oq;2|B)mWJ%*i8Fl;ToT=$6q<$ zR-$S^JVCm*)0Pkq_g_p+xcwQm8|y8&ye;!TAL}qRueh$sNJzG?fM6n%*kYC*MF`WXYPR zqC24BRDn1Tw{kKlBQq!{+L%nB&7!wyLwyr}1hyV0uX)~vM^ed%%{%*-(>=rC zMfitXy9W!7Z!+0L{t2q#3KuwAu2z|&IXYumbnJ)n=$m!V{+OKEuhnkK{80(MGI5~= zISyOzU}`DOrfb`3aO@K!16{6D>Ja;8Z+^a^F4$JKU|W0Ms6)6r71X4|?4Vtt-Ztai z9kM8I+!YJ05|yz_@krkI%3y`xNyIuONh^KkVpWK#Wrn(l%8ZYJuf5x@gapy|b36-? zEs(Eo8}NuY0`9lxjiAL4zXFLuCw>zkY5QvcfDJ_KF$GK1 z91w)##AD#34g5In0B(gM1TD({KMS{|&8P(M9W;wCaTzFLW5nS3z($6J%lCNiPX@Ew zYN|isKFM38eUOt!JPjK=j2s_Iq^x%$5nqEpfZ)zAIlU&t2p*K%e<%CXus%1*dQNIsGU9#Z`O~J}q)q7d}?L$qM$QqMLn5Fud!zy%-l#_kU zce3T>l$~Ydi<<7JE|@PY}LvtiZQgbUy7JH%;_o2)LaEbY432!?JsXr%l@Y6Fjl4Z{Amc%cN5T$zFy8- z|Gh=24a&LIgSkm9Ucz*K>$el$_WJ7u(;jYT|8%Ec^IXK+nI>IjHyH!O5c$gOeF=k4 zR0%46$h~D9rfmbN*?|dgSxNvkIv&eIQCrqtH6OKc3+>os<@=R?xc9ls$&`{8CpU`};v>M_1XOJ<{tw@(|>&u#U$HYfD~j||-P zAD6!WANH1St+}Af-AVO`?>MHFVr#D5{I;TMwtq@>s{e*vbw8t!89reP`-Oar8d_4x zkfyynpSG{1@vw|Rlq&9RMN|!`Y3*g8C-b=fTO8Dao3?MNB8c4F_-4c8Z4wYF#j} z^~(J9H<0}TwjW;CKgI0{+U!_d{3-d0#Yc~sJX}+j&H0`{Tww9Bq8T&u)1hag^KwXe z&e}MS6!>Y<9Xa_~*Pd==oCo*&kgYDsvhDDIyixqZt#Lcp8fK15Nnnvs)yWH&U5t#> z)CoBk9A5gk$qv}o`?QUn^Qm*SbT&Htq3J@m{Uta-I+a-9TK3pX!|6pr&fKbesu_Vf z+^~po`)C~S#@>ArM|nQs;v~5Pax4|*NStB$L{8BS4VHJ2(Uh}xMtZ<5ujr{4gFfeN zD!tA0?(5n0-{5h2HVqb5ybt47Wu{HInN+W%jkGk5!x-^R!KW6LxIgeTo1u_j5F8xR zCG4b@hIvCq3k~)$j5?dS&6DlfXB+zC>>?8zIlajEBr9LxNq%3ko=0k0{SbnJF6`?& zrYa>O0tNdD=rL6ZBzV7hK>c-7%cR4ijfcu#)BWQY&bfr0(yHDquc?W}>T0D&$H=LS zwMb7*6mtsA8`R?$Rx%4IBNG#2n#UM?i*aWxE11{J(WLpqf{%?UX;87;WDSg>wY*p@!9A zs#jL>IHr66iBx%!J?&%OkPy$G!}3wAIP6;cVllod5y6?DvtsdBBkI}$YN?&q#as&I zPjgs^^=>;>ni4Uc%39c0%fK$rhAo|^lJv04*{sPpY7>&XjAN}JD&vIwcE zQZb_PIg&>!q~Ty_Z7Mq#Mndwb$VpfTE!2SD-W8^-)O?<5H=aagg<a}lt;v@Bl%;*uP_dF=xY*&8#d=aX=|R<#nm=V&o<3+ zFU;9jJ*%)$Y^Jot(dH%&OHdi;`LeKON;_?gVT`k?Q8MpdDp_ zMQv4{MPV~ll=a0zJO~xrgI6z2#PccqRit+H4*7w2Ql7f4Qgm#Xj;{aA4p7hk7P`SE zC(G_n!)R5vD>PRklUm;te0)(8vxb-?U|BaYEGDAK%_P_uU+$*{L$R)!FnMFFb))vl zZAh#EVvU>28f7@_594UQhUdt1wIx?Jn-`@S&zq7eT* z-eQ;iLT^x^s?V;gjvDjno7X2%|#8T3GXj50I#a{Vr+HA9MVRae<9f3iilKh-R4Uq_-x z(Uf$-)DxgpuFjp+k)en=Rb|D_Js+GP>J>G;H1CNnAISA}>$K>p+QX9OEyku&2V2?^ zp(n?d;%9aIGEydw?e1$hrsCR{m||xTO-ik|w!W*TQd0C}b=PfVh9;y!yF$}>ahTon z;pp*Ud}JPk9{oIEl)Cb)@o^f zl$Pq_<7SHUzU-%%Fc@<|!|AW0J0D{`;Jw{&n?Eg_*3ebDBZN+Cd5Z*Z9fK%+wapwS z@12yLLxrZ*D|A@PC_gO*l-0}ryO&by9>oM7%eZ$a z_htIgCB;MhL&~%(6UI3S^lAT|czCzVu7p7krr(v>$=CgDs;;&+T`_O9`?Jmb$(#>V(kSN`1umJUfB(^fUelt zo*8W=edb~@{5yqHkc4d!J@LAYH2o;G;ZXL;=JUEJ8?piAarbjamOw?8300&)*YUIY zuLmNK&MF^s%16Bn?Or^9+k7=^EypvCLi9PbIJI%6$mY_64AN0ogE%vvxY{?pl zRwYmJg3N^bzJ$~U9p$&Eq~UVQPycC1QM%2BKAyKY2;s%2c;w@=#XaGG`|L zc%!{+4A;4(o$EYKq$5$}hmI)ksyLp}CdgMwK(da{3a)#-p2Z-IyubIEhvxkJL$c;V zLr}oxVJnCBDs{P~dMa9N>|KEu?!4YC!SL;ysF?CLRn@)%O@xC6HfW0k@SyON=-;Ld z1n&Q?e|=_{dt0RRC}*ai#xrA1k$$P7r0vcOpjx0@VH_eaqCy|wqcy}MpijJr!~ zQhMw=}$Eu zKi2Q%^g^b{XWBRLDrEVqp!K&Dw*C#uuH}E#K*@u4STR z&tf_SbYZ>6dk*<2##fN6OHMXFzF-6OwER-jp?T68kYMVsLW7zb<-$pRTjv9Yjxj}I z5+-Uw<3H?@*EF-?Stx7z=#|OD%h4~i*+fQvvS*Q(Lq*e%X$!A5+PM1TE4y^@TloPK zgJCAKS*tlyUy0RXw|3u8XiNb!i(O@>;s#@yqGiem#xkW*O>bXBMlR>v6zl@CF~A7K z+!E=96?!PsOSHjgsjtT+&}rW3%7p8_vTJ4zkDDl<7{|V~wA}48_EhwJpyUZ4e}#je zi0?`x;|5*sIBz@rWzmAZR15#Tlyt0>M#F{{!l+dj1aYOA=s-a`$a-5kzv$Aw3Tf<)j(O~r zqN<`yTM98Z?Fmd{mYbPc!xM}8j;VkF8|iUVv&&y_xGYf@3YXcKxztM5Og=YMQI$Vw zH$CdF`+CDi6x zWNc#6rfyR(h}zmq#C*8nVoxrssIXfvhamHfJh*;^di!^g8^!huyl~wlPgbO`^Rh>E(uC9s-3WGKaD9-9ET%=cXlLOcLxQIApf)nuMu*u4!TZ}Qu z8fkUW3egjm-#sS~_L|b`Q>kHX%{xaiws)tAzt%M`Q%6QdzH$eB2210~g$RVjy0?#y z>p+PCj^t;UwwtK&A7`O+lt0>Ajuz81htQgv9gOBPh=nQ9!L|^z@hreh_XVPix9-Vi z-TusCa_UJO4rdGR_IO>W$(%#681IOl@O()#Fy0gna!akyBKlQ@qVLt0b23%?y;S_l zKbdrpPrLnPaoG>dBkEnsXb2m~+?1db>>c8(<~=YuIq7Pin#vC4vR91t^_#}V#yCg$ z>{?h*P>^*t1SWpr?e7IzuGC6eOJq+=gi-mwD2hb&Fb(2SBcC$&jKzn>Az{7(GVrNdesKvLnWl#o5YA*39L z7<-U?#;t;wgmZdyEaqls^PdnSA|l|^1P30a^sY+bt+J17E(Jv;Wn^TGvP2+BLC!$p zbSBrx(C}+*ZEg3?VVoW^Jw;Nmb1s)V*w@!Q9dOfF4>}_v$d4K{&N92Ks3?KM;e`15 z`g%}_)oT@KPLQIa;$%lh$Bgf@&N8sB@VSY(fsT$V)ttGC+)>8YMD=Vb_6>y8pxi?} zRaLh;5rU+X1zs4gULo)t<9M&)q>aSh;J1WvW(kQ{sHQegfcjD-odB|M1P(KHF@$Sc85^>6Fq@-aRGy7clkVd^nM>DxZ@ioe33;e*zKNpfCZ5_*Yc ztfHKobCYYl+(Sh0(=!XDp;zI8dpq8Cc6MIzhr?~*aFE;G&dhTA>M#RsZ6=dxa|w$N znM@ufZ-##VUEp@#uK|W2!GZNc4glE>;PH7^I5*S>iIfw7_?*!HiRA@W$Gc~-)l@qy z)HE{khmV(+OP5aWxw~isKy<$fN4Pg^HK%p;^t6@zJU!L<^N}!+sB1%^jEP6vU zAV~3?(C79&R2XEEhaQp(DaY^e4VN+LnvwjQXHM7>yJ*T9iY! zffpOzs8g!}H_8#ZC^IJ~mE}9&3oL45atuKtEcl>z(2)x?COt z!5@4_K;MwkJ55Mi8%Zfov?+A3=T;Mmc#yTQmDH7AYT)qvp=?bsN%_aVi^OpPq6C-B z%0+Nz3LY>PCt9*GL;#8*fH`St>7^>>P|KwxjkNyr@7?$QasZltFT{t*clL-9{Sh^G zpPu!l2js(|oJ?XxMMW;NmqGz8(#`do&_77BW~15tB_ukzkOq7?R350V?O*(z$zGwO zWFsUbBtU+UDu&$9^1j5v17O!#!d*{{)<=h|%yf6(S43H|_irHdUG13y`-&II$$|E? z`e-@r(Pj59^p^WzGg=$Yo;$`H!t%Klt0F&;N@4S#J@%d$ee=T(zlLk4)faAsUI|+a z$IJqHjJ_Ha7Z>M`#lnC&Kd<%V+vVcP*^r8*C1-XUF(=3J_$F~Ec#E(e{+i{Ul4_Zq zZ8riDVQkA~S=!<}R?$oJ2U&yCk+Ev&AtYK^)y``D9{lS3xHCUyrP z5TdIb(N!YTY{coq5sWtkxm_0k(%2;@=WBU^zX_fOh|8W|jrod1%u^~uw80&)po(-q z9Om(O@3x+UAWPwKLS$29S_7wUmz9g@H~@2ND|09md1{(qvN@PG+s5UmE^mY8g=2={ zgIy)wps*!0A3_BMS)RrK#9ZTW^EEkD816lXGQVHI?rp9;KySSPPXy2z7OL}_UtK^k zvnOY-iVhAAw#LTB#yl3VNo%XL$=U$`2O9~@GLuWpx8ijNqG!&?{MWG7G>QAmM5^ul zN&@!$`SXIH8XFqkwBS*y(ME*`V)_y*Y{jH&`qNsm2$XtO5U%+h(^l*>%WQ+>%hsD6 zTnn=JJ<-*GD=@b6Y-brI%*PWDq%QcN+hs)7Wz~dk=NC4Mjo^+az0{6K zL%{Y=EhC`tPysCUBiL&Bj}sE$10|>7l3yZTl3a7om1#0ui~45f<2Yr2%{@ugEVUx!5ar4nr+GBmI6ZTm?`Em zyAJ`Bo)_NDnH@!}zTrNgOCS)ml`r2|h|0H$U!{Hke&eL`1hhOrK)RXctxEr3?)qW1 zQydz84rnV}JIC8DQvJjeR##Ol^o zkgfQXxe6N=nRp({E{r$>K_=F~nlmEkKR#AtZ%#JzJP1CNudO~8T!hbLNe>Hqu?mI8`gg(ML; zs_-j~7uqWqfh2NE1c>}B&|g2#-`(tps2K!FApq>9@R7Jbw35feOc05fB+#u{Fe$d; za|{LzB#MXD$|MO_zqIKe6}@@TbL;#>OA_O#%j8nouV>ck*w;ci&D8*+pgt@KQhw(> zw5TuOfVihmpMa{QA1SB+lBG5xr4CJbnw$TGy#QlvxiFL>)R`1cKvEqNAEHD4b4s^) zFc$(KH!ME3G}E2iSCW6tnw1f}+;M)XA$DIbgS@b?U>_>FT0gu(Coiq71qXTnL`MkV z_OmZgHTY6?trG7&8|~@Ya_~TyQ*tt+n&aa-X&=g8p;lBY+=4w*RrWK%%e6~GzEZ-= zfHQ^x4DB!7=S1aomKpHbok(;Di`~u@o4KbJyfOpd1zas%04Q`grp#Z<6D_H(ZIk&2 zEEWz=Y(8VnzCCKum1?Nt3IwI;Ap96Lgq{sC)`!dzp z{`8*wn8)xB+BlMRVUcmdV`{LtOu=!K-d z8^W;9a#=0m_|+=m}*%tz?(F=_pOJpHOq%WUEM+D%42CkQl7KC_j6T4P|4DD5xKaIn*@Mpdj&FDAjm;lP)}_ICHN(yis|4`9x(m=BQ?w2 zGVN$Z8e%HvR zS;GToUj5nGXJC|)#rihg5lSfl{A_1umn}(L0sYFxxpUjg>N-38YD??2UAKT!0v>UQ7gD0DzrQxw~A2q!-5Y?4O-FuS! z%>%B5@7uR;)!jK$bHRsNjix4G1-hY7vaTA6nYwt!46yj74Zx>l*(PEG?cH1pF4kaX zkX2$o5PCS$6SwS2OrHUEC0UDrH>~*NbYs0V;o4!LF4fc1gDuV{@j^ST;xOO0)^Nux z6U3oC78^DzjlKth@91r|T8*IjPz75Ol9&3yFMkw+}b=X~&XfXRyVta1qGxS|zYSWUo{ts~oKG+40$?)aU)RXDqX? zNr{1B$3p+~&>fwN868KkdZP=jYjU6GrvtwJfmk2((q$PwfL;5aHP|<6cHOsph#@)p z7WKW!@xX816JeT@(>bwXT89Qi9&4VlPSK}MhLWEQ>x+KLVf$q{R_+EHxGvnlq=}s| z&a;A2QeHV~fO=%0x*3KUTUfJxWuzEqcXpo86K2v*MsTWuvwr>b$c;G;KuI2d=4WjgwI1)7tjr7gDM6o%;G?Og~b&N5s9`5I}bsI zSA-jXay%Ih&jgAfE2rE*&evFx@FkUt!3#Y2-Q9#{{LQ^uBd&|ZdUhAVr~pp6Sx9#O zaN`Y%F*LzwcF|Jf4M9-)+fncx2I+4Bh8BVL*&zNbxvLiipK8a$sFEnB8M7UbZ*y(iNR24-8Q*TyKG$iT(R#2jB3eeJ|BoQMs1EZ z*0?L}oV!LY;hG;&xPMm+3qkAGh3WY>7^rPZ+rpEI{I%ap*S+l4rKigm{UE= zhxD{ie_$3A)OycJ4!Hkya^S`3mOdcS(sb{&9 zmX?+n*V+v5+{TRburJll9Lkz?WsL5 zm$GFaxpw3PUa7HGS21oW(4K5i8GD>cy?NKf(LWJm1-{mELjb4qN6yvcus?1NHo+4_ z@c^>mjzc?dmAlSm%dghIpg0sJ6%vb|Hahpu29@m#0M6|8xD9QA&KL-{mb~S$vY0V! ziBZW>H-vI?E&^eA!~upN59SQeR~En1f`1g|ftf}Gj0O{heirhYOv+_dgDqjqIs2Q( zb^`|x+PHRwWP^22@w3KZGILjpsu!?!L(!Q7ceTEcKntXoHjXr2ZAs;LgantFc|`0 zxDy77{|x`ZpHHU4etM`3eHU2^{vMcC7+N)ikmO%2?~_!p$KEY) z^bg+tUz3kKAc85u+k~qgfg*}hkv-whwrsgXZX=wEEUvt_a+FG)w3B<3%GWg!Y;i+5oqn*6FD%R&^o!6F%3lJyBweLdQhk?s%1(mnT(%#D=$^=ue`o%=jmg2 zvCeDf{r_4**un$54))Vm25q45kHV|Z0d82~aIKk$RA$eu=z3+a_yQN7l;*i{|X#Jf~L215lkz z$l`z8y34K3tZCOwxnYuJ)N`ivNdc9?s2t$6Z!+H&&$Oe^$%< zbTfak3=3ZSycVs8gB?IcUxL&QXYq9_^v2Tb(d*XG9{Y!P5n~+X;my#I*8($k$);gC zw(i;QLUa2H@3wGzDRKbT4!Ad?ltdQshwO1rXAO;MANM)$Nzn zCW`ne%=v-~HQ8P7);OZju$7RuAAkhP9n&nY+dy8N608K!#f)lL=h;>NWl%l^VEtdv0M4pA>UdSf-fgDo5~#qQ|Rjgc@X8EE)s0hc;n&ylRqCimjTA2 zvV~lO(%hCsG6HJ>57aJI5$!L9ku+*1(b0}4FxH6&m4Ffu3Yx=N1$1LG-7my$k3@l* zN&xnxw9}@2^*^N#C@7v~GVkaslsq7VPHUVx`7><`_qF!>zMuBwQW-Q6WspWkHe54# z=J5>>|NMdvTarN>0i%^SV*cv&psZJdg3$?zDI7i#MFbxKOONLdE)<2p#vBYcfl0_& zbqAo9>U7%g3&LA`bK=z<@W=5Qz~+$_B==7N3}dfYYI|+})H)DZ?m)ExRt5yjV?M7= z9ns$#8vxEMj4uoAc)RrbbY(&A2A^@#Of}dNlyF z@lR`UFy3!!XoK1gNDc_oTp>Po-T&Q7Ew+v5Iz?7ECPQ(72QtGYdF8#61KY0?PyNjd zyU{}K0m_o8m9E6x2QCw$d)N2vI46)Q){nm%Z~G?i2eS@9n7_Ig5|RvpOs{TrEz-e} zIswkWxDwEWoqaMYe-e5IJf79%KJ;Zj<>7z*__9}W?;qE+r;HS&@}H@-ZAt~904bv8 zz`3>~Avn9seTK;)lpkgEyPj5%;$M{jC7IYQvIgebqYS`}MjO~tO}HQ>O05b_+8u2M zq#&rinSfK;Q!Y)nl=)|RV!jSlh9rR6IJZ6KnXgvz8$j?tk*hzevRbo&BfboQ&H}^` za~$*^&jn?|>hi-&@Ri%@GhVu$kA9P3+uPe`0)-_G$^jv(Ympgao}sg6d#A6ySKws* zZi<|y!+4|9N91;9sTI__-OIuk%$nzgw0VU=CAO1+{-pSjQ#5c_zwW)bfJY>p?ts?M4#OK_f}~B&PG7n zB17sJOj{DI-nu8Jd}~z-k)Ov#RNYJD^Czj>#Upx>QJ@}yo!|!smBCr)03dsNKQ0Dl zp_;IMkJMH+_105r?rqLdPztTdF8+><3&E`;gJ_A6_3rL4N+%VBn)h)d)B$a$e*FY` zG=%_)8@xN|X(?Wookol-1uY6{*51J(3!tO6DmV^QUg!2w zggwU~^3$(+AZZj(B>~EFb90|xy$DDqBzlhJ5wr!<{zO~Ext`CQ?#d2B0JVHw81<8+ z{KAn9ZuHj}Mhh~!@Kl=RQcxd0M9R^g%;I~BKkJy58yOZBha;fS2Y?1;Q`JV##p4RO zgJL3a@=KNLJaeQrvWNcWc_*zMzeGI02ZctX3)`0%c`NtQYKiqu;OJ2d8or_?U^y|M z%)@#pA-)0B?jP0LXq$A)PXKO+meX8h$jsFT!r{}P?{?!c%L}?je18KwF&G3pWucGl zj_!H?)b*PjmLQb!7SZ*i))K?^`>`aVL7HVqu1|m!rrnTnD25M?cY0`|P)&wVVnmgB z;^x5Q<|^o{^HEgjXh~ngp%J_c?^JzX1WBzyrqWDe2%kUU*O!vDFIRr>cv6d?v@$+` zSxF^mQ>cV`6TFaxxBjR8P*^KICnAehvDg6aK%bi6`ln4%X}?y0`e zuD|I-rsi1+fZ2U8&Te0!tT1F=oFc55*T~)fLo9?;qap6|CSce6sb2|`;c*phYo6kd zVOnBdLHiq40BqFoFdz_xGXpnO)$y$FLq|iaS57HLFY%o?-eOTQ8Vz+LTe4H)8u{{e zHj%X>(%YIZ=7R!(|2D9`UhP&-H!ba5zs!)l4W6prpq^`_xsoN{+%j)nUUi?vo`gr> zs{Au2>UIwaYjsu;BMfSEwg=OXSth54_PjIc-4}#ERhD_!1lXdOFUXGeRv9P$yS!}evT#bG;tzd3s_V;S@C zF?)KZuq<{hlpIt+8iy-)htVUY=Qt-f`lK z4cK#{aL?BRW`Rcn0IJm=KAFuYy;rx^o&ueyfdb(F{P{uydFFY`=Hq2^GkI`_7AUV;GVK=^FYoRj*c#@v3|DJZi;DNCgJp8 z%R`_t_lOMxX+so&=HCh%nJlM0O@Iz4UJFa1)?$xjM9m`y(d~`V8ns*ub#F>XYRteb zQbGOFO7Y+OB5VJKcLA@SVgywP=4Xv*A`0N!bCLgeEh+E=s7(F9M+Kjs`;Tmmpd}EJ|?`Z=i!@3`Gf^{P{%j@t-0O8Y&dtRh+3W&mAiSea-%i zXe1CnfX&xKz-Ena6TsI@ikLhu2h{7uzOYQjJ}`a|_6=@R;4BHitfVCCJL%!j)R*XQ zKE*eIZdfL%m62$-?CmM{NLzTLNDQCLR9L2*I^+!MGJR71-a_zp(|2@sF71bW4G`H> zp(ag^8dk?hiAR7j1Zb3DUcaCBkDd)PA)Kn5qOT`8Srwv(Ci&kBJmc{KrsO`!W*26CfkOlB3R4chl=ld%L}ORf|;dh!8ykV{|m@g8jJt{