Compare commits

...

19 Commits

Author SHA1 Message Date
Cameron (3539)
75c289f526 Stop unknown coprocessor stats / no output from shell commands. (#1786)
Join the threads and wait for them to finish reading the input/error
streams before returning.
2025-02-17 15:58:16 -08:00
Sam Freund
e97865166d [build] Bump WPILib to 2025.3.1 (#1785) 2025-02-17 14:04:25 +08:00
Joseph Eng
53144bfcf1 Clean up pnp distance trig solve (#1781) 2025-02-14 21:20:05 -08:00
Joseph Eng
ee97a1b62e Add back pr template (#1782)
This has the contents of
https://github.com/PhotonVision/photonvision/pull/1561 at
04f63bdd6e,
which got force-pushed away before it got merged.

---------

Co-authored-by: Matt <matthew.morley.ca@gmail.com>
Co-authored-by: Gold856 <117957790+Gold856@users.noreply.github.com>
Co-authored-by: Jade <spacey-sooty@proton.me>
2025-02-14 18:37:11 -08:00
Julius
01a3d31734 Add 6328's implementation of PNP distance for Trig Solving to PhotonPoseEstimator (#1767)
https://discord.com/channels/725836368059826228/725846784131203222/1334309604946874460


https://www.chiefdelphi.com/t/frc-6328-mechanical-advantage-2025-build-thread/477314/85

Helps with ambiguous single tag estimates and produces more stability.
2025-02-13 11:45:18 -08:00
Jade
a546ff0819 Add version checking to C++ (#1774)
Signed-off-by: Jade Turner <spacey-sooty@proton.me>
Co-authored-by: Matt <matthew.morley.ca@gmail.com>
2025-02-12 23:23:11 -05:00
Sam Freund
67bc032d26 remove unnecessary readme badges (#1768)
closes #1766
2025-02-11 13:54:49 +08:00
Matt Morley
8f816cf1af Verify WPILib/OpenCV versions at runtime (#1772) 2025-02-10 17:52:48 -08:00
Craig Schardt
e2b028abdc Update install command for Romi (#1746)
Pending https://github.com/PhotonVision/photon-image-modifier/pull/49
2025-02-09 21:27:59 -06:00
Sam Freund
00fb4bdf07 Update Allowed Naming Conventions For Object Detection Models (#1749) 2025-02-09 07:12:47 -08:00
Sam Freund
7067c75525 Add YOLO11 Support (#1736) 2025-02-08 17:11:01 -08:00
Sam Freund
ef82328d74 Upload new algae RKNN model (#1758) 2025-02-03 15:52:20 -08:00
Sam Liu
be59e3a958 Add 3D tracking of the 2025 Algae in Colored Shape pipelines (#1756) 2025-02-01 14:11:01 -08:00
Joseph Eng
99427d888a Fix fallback for the multitag on rio pose strategy (#1755) 2025-02-01 14:09:43 -08:00
Sam Freund
8ec2da952f Yolo docs minimalist (#1723) 2025-01-28 10:05:23 -08:00
Anon Ymous
a5d007e258 Change SimCameraProperties to enable chaining of setters (#1731) 2025-01-28 10:03:03 -08:00
Gold856
78b82e3a96 Add FontAwesome assets (#1734)
Fixes #1472. TTF fonts were included as part of the FontAwesome kit, but
I opted to not add them because basically every browser won't use them
and it just pollutes the repo.
2025-01-19 23:58:47 -05:00
Gold856
1303a0eaae Fix typos and incorrect param order in Javadoc (#1740) 2025-01-19 22:56:47 -05:00
David Vo
ab41d2d1ed photonlibpy: Explicitly re-export (#1737) 2025-01-17 18:28:16 -08:00
80 changed files with 939 additions and 232 deletions

17
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,17 @@
## Description
<!-- What changed? Why? (the code + comments should speak for itself on the "how") -->
<!-- Fun screenshots or a cool video or something are super helpful as well. If this touches platform-specific behavior, this is where test evidence should be collected. -->
<!-- Any issues this pull request closes or pull requests this supersedes should be linked with `Closes #issuenumber`. -->
## Meta
Merge checklist:
- [ ] Pull Request title is [short, imperative summary](https://cbea.ms/git-commit/) of proposed changes
- [ ] The description documents the _what_ and _why_
- [ ] If this PR changes behavior or adds a feature, user documentation is updated
- [ ] If this PR touches photon-serde, all messages have been regenerated and hashes have not changed unexpectedly
- [ ] If this PR touches configuration, this is backwards compatible with settings back to v2024.3.1
- [ ] If this PR addresses a bug, a regression test for it is added

View File

@@ -1,6 +1,6 @@
# PhotonVision
[![CI](https://github.com/PhotonVision/photonvision/workflows/CI/badge.svg)](https://github.com/PhotonVision/photonvision/actions?query=workflow%3ACI) [![codecov](https://codecov.io/gh/PhotonVision/photonvision/branch/main/graph/badge.svg)](https://codecov.io/gh/PhotonVision/photonvision) [![Discord](https://img.shields.io/discord/725836368059826228?color=%23738ADB&label=Join%20our%20Discord&logo=discord&logoColor=white)](https://discord.gg/wYxTwym)
[![Discord](https://img.shields.io/discord/725836368059826228?color=%23738ADB&label=Join%20our%20Discord&logo=discord&logoColor=white)](https://discord.gg/wYxTwym)
PhotonVision is the free, fast, and easy-to-use computer vision solution for the *FIRST* Robotics Competition. You can read an overview of our features [on our website](https://photonvision.org). You can find our comprehensive documentation [here](https://docs.photonvision.org).

View File

@@ -4,7 +4,7 @@ plugins {
id "cpp"
id "com.diffplug.spotless" version "6.24.0"
id "edu.wpi.first.wpilib.repositories.WPILibRepositoriesPlugin" version "2020.2"
id "edu.wpi.first.GradleRIO" version "2025.2.1"
id "edu.wpi.first.GradleRIO" version "2025.3.1"
id 'edu.wpi.first.WpilibTools' version '1.3.0'
id 'com.google.protobuf' version '0.9.3' apply false
id 'edu.wpi.first.GradleJni' version '1.1.0'
@@ -33,14 +33,14 @@ ext.allOutputsFolder = file("$project.buildDir/outputs")
apply from: "versioningHelper.gradle"
ext {
wpilibVersion = "2025.2.1"
wpilibVersion = "2025.3.1"
wpimathVersion = wpilibVersion
openCVYear = "2025"
openCVversion = "4.10.0-3"
joglVersion = "2.4.0"
javalinVersion = "5.6.2"
libcameraDriverVersion = "v2025.0.3"
rknnVersion = "v2025.0.0"
rknnVersion = "dev-v2025.0.0-1-g33b6263"
frcYear = "2025"
mrcalVersion = "v2025.0.0";

View File

@@ -12,5 +12,6 @@ modifiableFileExclude {
\.ico$
\.rknn$
\.svg$
\.woff2$
gradlew
}

View File

@@ -1,16 +1,3 @@
/*!
* Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome
* License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
*/
@font-face {
font-family: FontAwesome;
src: url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713);
src: url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713?#iefix&v=4.7.0) format("embedded-opentype"), url(fonts/fontawesome-webfont.woff2?af7ae505a9eed503f8b8e6982036873e) format("woff2"), url(fonts/fontawesome-webfont.woff?fee66e712a8a08eef5805a46892932ad) format("woff"), url(fonts/fontawesome-webfont.ttf?b06871f281fee6b241d60582ae9369b9) format("truetype"), url(fonts/fontawesome-webfont.svg?912ec66d7572ff821749319396470bde#fontawesomeregular) format("svg");
font-weight: 400;
font-style:normal
}
.code-block-caption>.headerlink, dl dt>.headerlink, h1>.headerlink, h2>.headerlink, h3>.headerlink, h4>.headerlink, h5>.headerlink, h6>.headerlink, p.caption>.headerlink, table>caption>.headerlink {
font-family: FontAwesome;
font-size: 0.75em;

View File

@@ -0,0 +1,6 @@
/*!
* Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2024 Fonticons, Inc.
*/
@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype");unicode-range:u+f003,u+f006,u+f014,u+f016-f017,u+f01a-f01b,u+f01d,u+f022,u+f03e,u+f044,u+f046,u+f05c-f05d,u+f06e,u+f070,u+f087-f088,u+f08a,u+f094,u+f096-f097,u+f09d,u+f0a0,u+f0a2,u+f0a4-f0a7,u+f0c5,u+f0c7,u+f0e5-f0e6,u+f0eb,u+f0f6-f0f8,u+f10c,u+f114-f115,u+f118-f11a,u+f11c-f11d,u+f133,u+f147,u+f14e,u+f150-f152,u+f185-f186,u+f18e,u+f190-f192,u+f196,u+f1c1-f1c9,u+f1d9,u+f1db,u+f1e3,u+f1ea,u+f1f7,u+f1f9,u+f20a,u+f247-f248,u+f24a,u+f24d,u+f255-f25b,u+f25d,u+f271-f274,u+f278,u+f27b,u+f28c,u+f28e,u+f29c,u+f2b5,u+f2b7,u+f2ba,u+f2bc,u+f2be,u+f2c0-f2c1,u+f2c3,u+f2d0,u+f2d2,u+f2d4,u+f2dc}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-v4compatibility.woff2) format("woff2"),url(../webfonts/fa-v4compatibility.ttf) format("truetype");unicode-range:u+f041,u+f047,u+f065-f066,u+f07d-f07e,u+f080,u+f08b,u+f08e,u+f090,u+f09a,u+f0ac,u+f0ae,u+f0b2,u+f0d0,u+f0d6,u+f0e4,u+f0ec,u+f10a-f10b,u+f123,u+f13e,u+f148-f149,u+f14c,u+f156,u+f15e,u+f160-f161,u+f163,u+f175-f178,u+f195,u+f1f8,u+f219,u+f27a}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -79,6 +79,7 @@ source_suffix = [".rst", ".md"]
def setup(app):
app.add_css_file("css/v4-font-face.min.css")
app.add_css_file("css/pv-icons.css")

View File

@@ -15,12 +15,12 @@ SSH into the Raspberry Pi (using Windows command line, or a tool like [Putty](ht
:::{attention}
The version of WPILibPi for the Romi is 2023.2.1, which is not compatible with the current version of PhotonVision. **If you are using WPILibPi 2023.2.1 on your Romi, you must install PhotonVision v2023.4.2 or earlier!**
To install a compatible version of PhotonVision, enter these commands in the SSH terminal connected to the Raspberry Pi. This will download and run the install script, which will intall PhotonVision on your Raspberry Pi and configure it to run at startup.
To install a compatible version of PhotonVision, enter these commands in the SSH terminal connected to the Raspberry Pi. This will download and run the install script, which will install PhotonVision on your Raspberry Pi and configure it to run at startup.
```bash
$ wget https://git.io/JJrEP -O install.sh
$ sudo chmod +x install.sh
$ sudo ./install.sh -v 2023.4.2
$ sudo ./install.sh -v v2023.4.2
```
The install script requires an internet connection, so connecting the Raspberry Pi to an internet-connected router via an Ethernet cable will be the easiest solution. The pi must remain writable while you are following these steps!
:::

View File

@@ -12,7 +12,7 @@ Bonjour provides more stable networking when using Windows PCs. Install [Bonjour
## Installing Java
PhotonVision requires a JDK installed and on the system path. **JDK 17 is needed. Windows Users must use the JDK that ships with WPILib.** [Download and install it from here.](https://github.com/wpilibsuite/allwpilib/releases/tag/v2025.2.1) Either ensure the only Java on your PATH is the WPILIB Java or specify it to gradle with `-Dorg.gradle.java.home=C:\Users\Public\wpilib\2025\jdk`:
PhotonVision requires a JDK installed and on the system path. **JDK 17 is needed. Windows Users must use the JDK that ships with WPILib.** [Download and install it from here.](https://github.com/wpilibsuite/allwpilib/releases/tag/v2025.3.1) Either ensure the only Java on your PATH is the WPILIB Java or specify it to gradle with `-Dorg.gradle.java.home=C:\Users\Public\wpilib\2025\jdk`:
```
> ./gradlew run "-Dorg.gradle.java.home=C:\Users\Public\wpilib\2025\jdk"

View File

@@ -50,7 +50,7 @@ When a new camera (ie, one we can't match by-path to a deserialized CameraConfig
## Startup:
- GIVEN An emtpy set of deserialized Camera Configurations
- GIVEN An empty set of deserialized Camera Configurations
<br>WHEN PhotonVision starts
<br>THEN no VisionModules will be started
@@ -72,12 +72,12 @@ When a new camera (ie, one we can't match by-path to a deserialized CameraConfig
## Camera (re)enumeration:
- GIVEN a NEW USB CAMERA is avaliable for enumeration
- GIVEN a NEW USB CAMERA is available for enumeration
<br>WHEN a USB camera is discovered by VisionSourceManager
<br>AND the USB camera's VIDEO DEVICE PATH is not in the set of DESERIALIZED CAMERA CONFIGURATIONS
<br>THEN a UNIQUE NAME will be assigned to the camera info
- GIVEN a NEW USB CAMERA is avaliable for enumeration
- GIVEN a NEW USB CAMERA is available for enumeration
<br>WHEN a USB camera is discovered by VisionSourceManager
<br>AND the USB camera's VIDEO DEVICE PATH is in the set of DESERIALIZED CAMERA CONFIGURATIONS
<br>THEN a UNIQUE NAME equal to the matching DESERIALIZED CAMERA CONFIGURATION will be assigned to the camera info
@@ -86,13 +86,13 @@ When a new camera (ie, one we can't match by-path to a deserialized CameraConfig
## Creating from a new camera
- Given: A UNIQUE NAME from a NEW USB CAMERA
<br>WHEN I request a new VisionModule is created for this NEW USB CAMREA
<br>WHEN I request a new VisionModule is created for this NEW USB CAMERA
<br>AND the camera has a VALID USB PATH
<br>AND the camera's VALID USB PATH is not in use by any CURRENTLY ACTIVE CAMERAS
<br>THEN a NEW VisionModule will be started for the NEW USB CAMERA using the VALID USB PATH
- Given: A UNIQUE NAME from a NEW USB CAMERA
<br>WHEN I request a new VisionModule is created for this NEW USB CAMREA
<br>WHEN I request a new VisionModule is created for this NEW USB CAMERA
<br>AND the camera does not have a VALID USB PATH
<br>AND the camera's VIDEO DEVICE PATH is not in use by any CURRENTLY ACTIVE CAMERAS
<br>THEN a NEW VisionModule will be started for the NEW USB CAMERA using the VIDEO DEVICE PATH

View File

@@ -3,7 +3,7 @@
## A primer on time
Expecially starting around 2022 with AprilTags making localization easier, providing a way to know when a camera image was captured at became more important for localization.
Especially starting around 2022 with AprilTags making localization easier, providing a way to know when a camera image was captured at became more important for localization.
Since the [creation of USBFrameProvider](https://github.com/PhotonVision/photonvision/commit/f92bf670ded52b59a00352a4a49c277f01bae305), we used the time [provided by CSCore](https://github.wpilib.org/allwpilib/docs/release/java/edu/wpi/first/cscore/CvSink.html#grabFrame(org.opencv.core.Mat)) to tell when a camera image was captured at, but just keeping track of "CSCore told us frame N was captured 104.21s after the Raspberry Pi turned on" isn't very helpful. We can decompose this into asking:
- At what time was a particular image captured at, in the coprocessor's timebase?
@@ -29,13 +29,13 @@ WPILib's CSCore is a platform-agnostic wrapper around Windows, Linux, and MacOS
Prior to https://github.com/wpilibsuite/allwpilib/pull/7609, CSCore used the [time it dequeued the buffer at](https://github.com/wpilibsuite/allwpilib/blob/17a03514bad6de195639634b3d57d5ac411d601e/cscore/src/main/native/linux/UsbCameraImpl.cpp#L559) as the image capture time. But this doesn't account for exposure time or latency introduced by the camera + USB stack + Linux itself.
V4L does expose (with some [very heavy caviets](https://github.com/torvalds/linux/blob/fc033cf25e612e840e545f8d5ad2edd6ba613ed5/drivers/media/usb/uvc/uvc_video.c#L600) for some troublesome cameras) its best guess at the time an image was captured at via [buffer flags](https://www.kernel.org/doc/html/v4.9/media/uapi/v4l/buffer.html#buffer-flags). In my testing, all my cameras were able to provide timestamps with both these flags set:
V4L does expose (with some [very heavy caveats](https://github.com/torvalds/linux/blob/fc033cf25e612e840e545f8d5ad2edd6ba613ed5/drivers/media/usb/uvc/uvc_video.c#L600) for some troublesome cameras) its best guess at the time an image was captured at via [buffer flags](https://www.kernel.org/doc/html/v4.9/media/uapi/v4l/buffer.html#buffer-flags). In my testing, all my cameras were able to provide timestamps with both these flags set:
- `V4L2_BUF_FLAG_TIMESTAMP_MONOTONIC`: The buffer timestamp has been taken from the CLOCK_MONOTONIC clock [...] accessible via `clock_gettime()`.
- `V4L2_BUF_FLAG_TSTAMP_SRC_SOE`: Start Of Exposure. The buffer timestamp has been taken when the exposure of the frame has begun.
I'm sure that we'll find a camera that doesn't play nice, because we can't have nice things :). But until then, using this timestamp gets us a free accuracy bump.
Other things to note: This gets us an estimate at when the camera *started* collecting photons. The camera's sensor will remain collecitng light for up to the total integration time, plus readout time for rolling shutter cameras.
Other things to note: This gets us an estimate at when the camera *started* collecting photons. The camera's sensor will remain collecting light for up to the total integration time, plus readout time for rolling shutter cameras.
## Latency Testing
@@ -105,7 +105,7 @@ public class Robot extends TimedRobot {
```
</details>
I've decreased camera exposure as much as possible (so we know with reasonable confidence that the image was collected right at the start of the exposure time reported by V4L), but we only get back new images at 60fps. So we don't know when between frame N and N+1 the LED turned on - just that somtime between now and 1/60th of a second a go, the LED turned on.
I've decreased camera exposure as much as possible (so we know with reasonable confidence that the image was collected right at the start of the exposure time reported by V4L), but we only get back new images at 60fps. So we don't know when between frame N and N+1 the LED turned on - just that sometime between now and 1/60th of a second a go, the LED turned on.
The test coprocessor was an Orange Pi 5 running a PhotonVision 2025 (Ubuntu 24.04 based) image, with an ArduCam OV9782 at 1280x800, 60fps, MJPG running a reflective pipeline.
@@ -133,4 +133,4 @@ With the camera capturing at 60fps, the time between successive frames is only ~
### Future Work
This test also makes no effort to isolate error from time syncronization from error introduced by frame time measurement - we're just interested in overall error. Future work could investigate the latency contribution
This test also makes no effort to isolate error from time synchronization from error introduced by frame time measurement - we're just interested in overall error. Future work could investigate the latency contribution

View File

@@ -76,7 +76,7 @@ Communication between server and clients shall occur over the User Datagram Prot
## Message Format
The message format forgoes CRCs (as these are provided by the Ethernet physical layer) or packet delimination (as our packetsa are assumed be under the network MTU). **TSP Ping** and **TSP Pong** messages shall be encoded in a manor compatible with a WPILib packed struct with respect to byte alignment and endienness.
The message format forgoes CRCs (as these are provided by the Ethernet physical layer) or packet delineation (as our packets are assumed be under the network MTU). **TSP Ping** and **TSP Pong** messages shall be encoded in a manor compatible with a WPILib packed struct with respect to byte alignment and endianness.
### TSP Ping
@@ -98,7 +98,7 @@ The message format forgoes CRCs (as these are provided by the Ethernet physical
## Optional Protocol Extensions
Clients may publish statistics to NetworkTables. If they do, they shall publish to a key that is globally unique per participant in the Time Synronization network. If a client implements this, it shall provide the following publishers:
Clients may publish statistics to NetworkTables. If they do, they shall publish to a key that is globally unique per participant in the Time Synchronization network. If a client implements this, it shall provide the following publishers:
| Key | Type | Notes |
| ------ | ------ | ---- |

View File

@@ -27,7 +27,7 @@ Using PhotonVision allows the user to calibrate for their specific camera, which
### Low Latency, High FPS Processing
PhotonVision exposes specalized hardware on select coprocessors to maximize processing speed. This allows for lower-latency detection of targets to ensure you aren't losing out on any performance.
PhotonVision exposes specialized hardware on select coprocessors to maximize processing speed. This allows for lower-latency detection of targets to ensure you aren't losing out on any performance.
### Fully Open Source and Active Developer Community

View File

@@ -5,8 +5,8 @@ The following example is from the PhotonLib example repository ([Java](https://g
## Knowledge and Equipment Needed
- A Robot
- A camera mounted rigidly to the robot's frame, cenetered and pointed forward.
- A coprocessor running PhotonVision with an AprilTag or Aurco 2D Pipeline.
- A camera mounted rigidly to the robot's frame, centered and pointed forward.
- A coprocessor running PhotonVision with an AprilTag or Aruco 2D Pipeline.
- [A printout of AprilTag 7](https://firstfrc.blob.core.windows.net/frc2025/FieldAssets/Apriltag_Images_and_User_Guide.pdf), mounted on a rigid and flat surface.
## Code

View File

@@ -2,15 +2,15 @@
## How does it work?
PhotonVision supports object detection using neural network accelerator hardware built into Orange Pi 5/5+ coprocessors. The Neural Processing Unit, or NPU, is [used by PhotonVision](https://github.com/PhotonVision/rknn_jni/tree/main) to massively accelerate certain math operations like those needed for running ML-based object detection.
PhotonVision supports object detection using neural network accelerator hardware built into Orange Pi 5/5+ coprocessors. Please note that the Orange Pi 5/5+ are the only coprocessors that are currently supported. The Neural Processing Unit, or NPU, is [used by PhotonVision](https://github.com/PhotonVision/rknn_jni/tree/main) to massively accelerate certain math operations like those needed for running ML-based object detection.
For the 2025 season, PhotonVision does not currently ship with a pre-trained detector. If teams are interested in using object detection, they can follow the custom process outlined {ref}`below <docs/objectDetection/about-object-detection:Uploading Custom Models>`.
For the 2025 season, PhotonVision ships with a pretrained ALGAE model. A model to detect coral is not currently stable, and interested teams should ask in the Photonvision discord.
## Tracking Objects
Before you get started with object detection, ensure that you have followed the previous sections on installation, wiring, and networking. Next, open the Web UI, go to the top right card, and switch to the “Object Detection” type. You should see a screen similar to the image above.
PhotonVision does not currently ship with a pretrained model. Models are trained to detect one or more object "classes" (such as cars, stoplights) in an input image. For each detected object, the model outputs a bounding box around where in the image the object is located, what class the object belongs to, and a unitless confidence between 0 and 1.
Models are trained to detect one or more object "classes" (such as cars, stoplights) in an input image. For each detected object, the model outputs a bounding box around where in the image the object is located, what class the object belongs to, and a unitless confidence between 0 and 1.
:::{note}
This model output means that while its fairly easy to say that "this rectangle probably contains an object", we don't have any information about the object's orientation or location. Further math in user code would be required to make estimates about where an object is physically located relative to the camera.
@@ -35,16 +35,21 @@ Photonvision will letterbox your camera frame to 640x640. This means that if you
## Training Custom Models
Coming soon!
:::{warning}
Power users only. This requires some setup, such as obtaining your own dataset and installing various tools. It's additionally advised to have a general knowledge of ML before attempting to train your own model. Additionally, this is not officially supported by Photonvision, and any problems that may arise are not attributable to Photonvision.
:::
Before beginning, it is necessary to install the [rknn-toolkit2](https://github.com/airockchip/rknn-toolkit2). Then, install the relevant [Ultralytics repository](https://github.com/airockchip?tab=repositories&q=yolo&type=&language=&sort=) from this list. After training your model, export it to `rknn`. This will give you an `onnx` file, formatted for conversion. Copy this file to the relevant folder in [rknn_model_zoo](https://github.com/airockchip/rknn_model_zoo), and use the conversion script located there to convert it. If necessary, modify the script to provide the path to your training database for quantization.
## Uploading Custom Models
:::{warning}
PhotonVision currently ONLY supports 640x640 YOLOv5 & YOLOv8 models trained and converted to `.rknn` format for RK3588 CPUs! Other models require different post-processing code and will NOT work. The model conversion process is also highly particular. Proceed with care.
PhotonVision currently ONLY supports 640x640 Ultralytics YOLOv5, YOLOv8, and YOLO11 models trained and converted to `.rknn` format for RK3588 CPUs! Other models require different post-processing code and will NOT work. The model conversion process is also highly particular. Proceed with care.
:::
In the settings, under `Device Control`, there's an option to upload a new object detection model. Naming convention
should be `name-verticalResolution-horizontalResolution-modelType`. Additionally, the labels
file ought to have the same name as the RKNN file, with `-labels` appended to the end. For example, if the
RKNN file is named `note-640-640-yolov5s.rknn`, the labels file should be named
`note-640-640-yolov5s-labels.txt`.
should be `name-verticalResolution-horizontalResolution-modelType`. The
`name` should only include alphanumeric characters, periods, and underscores. Additionally, the labels
file ought to have the same name as the RKNN file, with `-labels` appended to the end. For
example, if the RKNN file is named `Algae_1.03.2025-640-640-yolov5s.rknn`, the labels file should be
named `Algae_1.03.2025-640-640-yolov5s-labels.txt`.

View File

@@ -106,7 +106,7 @@ You can get a [translation](https://docs.wpilib.org/en/latest/docs/software/adva
.. code-block:: C++
// Calculate a translation from the camera to the target.
frc::Translation2d translation = photonlib::PhotonUtils::EstimateCameraToTargetTranslationn(
frc::Translation2d translation = photonlib::PhotonUtils::EstimateCameraToTargetTranslation(
distance, frc::Rotation2d(units::degree_t(-target.GetYaw())));
.. code-block:: Python

View File

@@ -17,6 +17,6 @@ Arducam cameras are supported for setups with multiple devices. This is possible
3. **Save Settings**: Ensure that you save the settings after selecting the appropriate camera model for each device.
```{image} images/setArducamModel.png
:alt: The camera model can be selected from the Arudcam model selector in the cameras tab
:alt: The camera model can be selected from the Arducam model selector in the cameras tab
:align: center
```

View File

@@ -26,7 +26,8 @@ const interactiveCols = computed(() =>
{ name: '2020 Power Cell (7in)', value: TargetModel.CircularPowerCell7in },
{ name: '2022 Cargo Ball (9.5in)', value: TargetModel.RapidReactCircularCargoBall },
{ name: '2023 AprilTag 6in (16h5)', value: TargetModel.AprilTag6in_16h5 },
{ name: '2024 AprilTag 6.5in (36h11)', value: TargetModel.AprilTag6p5in_36h11 }
{ name: '2024 AprilTag 6.5in (36h11)', value: TargetModel.AprilTag6p5in_36h11 },
{ name: '2025 Algae (16.25in)', value: TargetModel.ReefscapeAlgae }
]"
:select-cols="interactiveCols"
@input="(value) => useCameraSettingsStore().changeCurrentPipelineSetting({ targetModel: value }, false)"

View File

@@ -88,11 +88,12 @@ const supportedModels = computed(() => {
<v-card-title>Import New Object Detection Model</v-card-title>
<v-card-text>
Upload a new object detection model to this device that can be used in a pipeline. Naming convention
should be <code>name-verticalResolution-horizontalResolution-modelType</code>. Additionally, the labels
file ought to have the same name as the RKNN file, with <code>-labels</code> appended to the end. For
example, if the RKNN file is named <code>note-640-640-yolov5s.rknn</code>, the labels file should be
named <code>note-640-640-yolov5s-labels.txt</code>. Note that ONLY 640x640 YOLOv5 & YOLOv8 models
trained and converted to `.rknn` format for RK3588 CPUs are currently supported!
should be <code>name-verticalResolution-horizontalResolution-modelType</code>. The
<code>name</code> should only include alphanumeric characters, periods, and underscores. Additionally,
the labels file ought to have the same name as the RKNN file, with <code>-labels</code> appended to the
end. For example, if the RKNN file is named <code>note-640-640-yolov5s.rknn</code>, the labels file
should be named <code>note-640-640-yolov5s-labels.txt</code>. Note that ONLY 640x640 YOLOv5 & YOLOv8
models trained and converted to `.rknn` format for RK3588 CPUs are currently supported!
<v-row class="mt-6 ml-4 mr-8">
<v-file-input v-model="importRKNNFile" label="RKNN File" accept=".rknn" />
</v-row>

View File

@@ -27,7 +27,8 @@ export enum TargetModel {
CircularPowerCell7in = 3,
RapidReactCircularCargoBall = 4,
AprilTag6in_16h5 = 5,
AprilTag6p5in_36h11 = 6
AprilTag6p5in_36h11 = 6,
ReefscapeAlgae = 7
}
export interface PipelineSettings {

View File

@@ -19,7 +19,7 @@ package org.photonvision.common.configuration;
/**
* Add migrations by adding the SQL commands for each migration sequentially to this array. DO NOT
* edit or delete existing SQL commands. That will lead to producing an icompatible database.
* edit or delete existing SQL commands. That will lead to producing an incompatible database.
*
* <p>You can use multiple SQL statements in one migration step as long as you separate them with a
* semicolon (;).

View File

@@ -33,6 +33,8 @@ import java.util.Map;
import java.util.Optional;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.photonvision.common.hardware.Platform;
import org.photonvision.common.logging.LogGroup;
import org.photonvision.common.logging.Logger;
@@ -301,4 +303,66 @@ public class NeuralNetworkModelManager {
logger.error("Error extracting models", e);
}
}
private static Pattern modelPattern =
Pattern.compile("^([a-zA-Z0-9._]+)-(\\d+)-(\\d+)-(yolov(?:5|8|11)[nsmlx]*)\\.rknn$");
private static Pattern labelsPattern =
Pattern.compile("^([a-zA-Z0-9._]+)-(\\d+)-(\\d+)-(yolov(?:5|8|11)[nsmlx]*)-labels\\.txt$");
/**
* Check naming conventions for models and labels.
*
* <p>This is static as it is not dependent on the state of the class.
*
* @param modelName the name of the model
* @param labelsName the name of the labels file
* @throws IllegalArgumentException if the names are invalid
*/
public static void verifyRKNNNames(String modelName, String labelsName) {
// check null
if (modelName == null || labelsName == null) {
throw new IllegalArgumentException("Model name and labels name cannot be null");
}
// These patterns check that the naming convention of
// name-widthResolution-heightResolution-modelType is followed
Matcher modelMatcher = modelPattern.matcher(modelName);
Matcher labelsMatcher = labelsPattern.matcher(labelsName);
if (!modelMatcher.matches() || !labelsMatcher.matches()) {
throw new IllegalArgumentException(
"Model name and labels name must follow the naming convention of name-widthResolution-heightResolution-modelType.rknn and name-widthResolution-heightResolution-modelType-labels.txt");
}
if (!modelMatcher.group(1).equals(labelsMatcher.group(1))
|| !modelMatcher.group(2).equals(labelsMatcher.group(2))
|| !modelMatcher.group(3).equals(labelsMatcher.group(3))
|| !modelMatcher.group(4).equals(labelsMatcher.group(4))) {
throw new IllegalArgumentException("Model name and labels name must be matching.");
}
}
/**
* Parse RKNN name and return the name, width, height, and model type.
*
* <p>This is static as it is not dependent on the state of the class.
*
* @param modelName the name of the model
* @throws IllegalArgumentException if the model name does not follow the naming convention
* @return an array containing the name, width, height, and model type
*/
public static String[] parseRKNNName(String modelName) {
Matcher modelMatcher = modelPattern.matcher(modelName);
if (!modelMatcher.matches()) {
throw new IllegalArgumentException(
"Model name must follow the naming convention of name-widthResolution-heightResolution-modelType.rknn");
}
return new String[] {
modelMatcher.group(1), modelMatcher.group(2), modelMatcher.group(3), modelMatcher.group(4)
};
}
}

View File

@@ -28,7 +28,7 @@ import org.photonvision.common.logging.Logger;
* Our blessed images inject the current version via this build workflow:
* https://github.com/PhotonVision/photon-image-modifier/blob/2e5ddb6b599df0be921c12c8dbe7b939ecd7f615/.github/workflows/main.yml#L67
*
* <p>This class provides a convienent abstraction around this
* <p>This class provides a convenient abstraction around this
*/
public class OsImageVersion {
private static final Logger logger = new Logger(OsImageVersion.class, LogGroup.General);

View File

@@ -145,8 +145,9 @@ public class ShellExec {
exitCode = 0;
if (wait) {
try {
process.waitFor();
exitCode = process.exitValue();
exitCode = process.waitFor();
errorGobbler.join();
outputGobbler.join();
} catch (InterruptedException ignored) {
}
}

View File

@@ -31,7 +31,7 @@ public enum CameraQuirk {
/** Separate red/blue gain controls available */
@JsonAlias("AWBGain") // remove after https://github.com/PhotonVision/photonvision/issues/1488
AwbRedBlueGain,
/** Will not work with photonvision - Logitec C270 at least */
/** Will not work with photonvision - Logitech C270 at least */
CompletelyBroken,
/** Has adjustable focus and autofocus switch */
AdjustableFocus,

View File

@@ -31,9 +31,9 @@ public class QuirkyCamera {
// SeeCam, which has an odd exposure range
new QuirkyCamera(
0x2560, 0xc128, "See3Cam_24CUG", CameraQuirk.Gain, CameraQuirk.See3Cam_24CUG),
// Chris's older generic "Logitec HD Webcam"
// Chris's older generic "Logitech HD Webcam"
new QuirkyCamera(0x9331, 0x5A3, CameraQuirk.CompletelyBroken),
// Logitec C270
// Logitech C270
new QuirkyCamera(0x825, 0x46D, CameraQuirk.CompletelyBroken),
// A laptop internal camera someone found broken
new QuirkyCamera(0x0bda, 0x5510, CameraQuirk.CompletelyBroken),

View File

@@ -180,7 +180,7 @@ public class USBCameraSource extends VisionSource {
// And update the settables' FrameStaticProps
settables.setVideoMode(oldVideoMode);
// Propogate our updated settables over to the frame provider
// Propagate our updated settables over to the frame provider
((USBFrameProvider) this.usbFrameProvider).updateSettables(this.settables);
}

View File

@@ -145,7 +145,7 @@ public class FileSaveFrameConsumer implements Consumer<CVMat> {
}
/**
* Returns the match Data collected from the NT. eg : Q58 for qualfication match 58. If not in
* Returns the match Data collected from the NT. eg : Q58 for qualification match 58. If not in
* event, returns N/A-0-EVENTNAME
*/
private String getMatchData() {

View File

@@ -23,6 +23,7 @@ import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;
import org.opencv.core.Size;
import org.photonvision.common.configuration.NeuralNetworkModelManager;
import org.photonvision.jni.RknnObjectDetector;
import org.photonvision.rknn.RknnJNI;
@@ -39,6 +40,8 @@ public class RknnModel implements Model {
*
* <p>"yolov8" -> "YOLO_V8"
*
* <p>"yolov11" -> "YOLO_V11"
*
* @param modelName The model's filename
* @return The model version
*/
@@ -48,6 +51,8 @@ public class RknnModel implements Model {
return RknnJNI.ModelVersion.YOLO_V5;
} else if (modelName.contains("yolov8")) {
return RknnJNI.ModelVersion.YOLO_V8;
} else if (modelName.contains("yolov11")) {
return RknnJNI.ModelVersion.YOLO_V11;
} else {
throw new IllegalArgumentException("Unknown model version for model " + modelName);
}
@@ -63,10 +68,8 @@ public class RknnModel implements Model {
public RknnModel(File modelFile, String labels) throws IllegalArgumentException, IOException {
this.modelFile = modelFile;
String[] parts = modelFile.getName().split("-");
if (parts.length != 4) {
throw new IllegalArgumentException("Invalid model file name: " + modelFile);
}
// parseRKNNName throws an IllegalArgumentException if the model name is invalid
String[] parts = NeuralNetworkModelManager.parseRKNNName(modelFile.getName());
this.version = getModelVersion(parts[3]);

View File

@@ -444,7 +444,7 @@ public class VisionModule {
return false;
}
visionRunner.runSyncronously(
visionRunner.runSynchronously(
() -> {
settables.setVideoModeInternal(pipelineSettings.cameraVideoModeIndex);
settables.setBrightness(pipelineSettings.cameraBrightness);

View File

@@ -206,7 +206,7 @@ public class VisionModuleChangeSubscriber extends DataChangeSubscriber {
parentModule.startCalibration(deserialized);
parentModule.saveAndBroadcastAll();
} catch (Exception e) {
logger.error("Error deserailizing start-calibration request", e);
logger.error("Error deserializing start-calibration request", e);
}
}

View File

@@ -92,7 +92,7 @@ public class VisionRunner {
}
}
public Future<Void> runSyncronously(Runnable runnable) {
public Future<Void> runSynchronously(Runnable runnable) {
CompletableFuture<Void> future = new CompletableFuture<>();
synchronized (runnableList) {
@@ -109,7 +109,7 @@ public class VisionRunner {
return future;
}
public <T> Future<T> runSyncronously(Callable<T> callable) {
public <T> Future<T> runSynchronously(Callable<T> callable) {
CompletableFuture<T> future = new CompletableFuture<>();
synchronized (runnableList) {

View File

@@ -366,7 +366,7 @@ public class VisionSourceManager {
* CameraConfiguration}'s matchedCameraInfo. We depend on the underlying {@link VisionSource} to
* be robust to disconnected sources at boot
*
* <p>Verify that nickname is unique within the set of desesrialized camera configurations, adding
* <p>Verify that nickname is unique within the set of deserialized camera configurations, adding
* random characters if this isn't the case
*/
protected VisionSource loadVisionSourceFromCamConfig(CameraConfiguration configuration) {

View File

@@ -70,7 +70,7 @@ public abstract class VisionSourceSettables {
public abstract void setGain(int gain);
// Pretty uncommon so instead of abstract this is just a no-op by default
// Overriddenn by cameras with AWB gain support
// Overridden by cameras with AWB gain support
public void setRedGain(int red) {}
public void setBlueGain(int blue) {}

View File

@@ -108,6 +108,25 @@ public enum TargetModel implements Releasable {
-Units.inchesToMeters(9.5) / 2,
-Units.inchesToMeters(9.5) / 2)),
0),
k2025Algae(
List.of(
new Point3(
-Units.inchesToMeters(16.25) / 2,
-Units.inchesToMeters(16.25) / 2,
-Units.inchesToMeters(16.25) / 2),
new Point3(
-Units.inchesToMeters(16.25) / 2,
Units.inchesToMeters(16.25) / 2,
-Units.inchesToMeters(16.25) / 2),
new Point3(
Units.inchesToMeters(16.25) / 2,
Units.inchesToMeters(16.25) / 2,
-Units.inchesToMeters(16.25) / 2),
new Point3(
Units.inchesToMeters(16.25) / 2,
-Units.inchesToMeters(16.25) / 2,
-Units.inchesToMeters(16.25) / 2)),
0),
// 2023 AprilTag, with 6 inch marker width (inner black square).
@JsonAlias({"k6in_16h5"})
kAprilTag6in_16h5(

View File

@@ -287,7 +287,7 @@ public class Calibrate3dPipeTest {
}
/**
* Uses a given camera coefficientss matrix set to "undistort" every image file found in a given
* Uses a given camera coefficients matrix set to "undistort" every image file found in a given
* directory and display them. Provides an easy way to visually debug the results of the
* calibration routine. Seems to play havoc with CI and takes a chunk of time, so shouldn't
* usually be left active in tests.

View File

@@ -139,7 +139,7 @@ public class CalibrationRotationPipeTest {
Point[] originalPoints = {new Point(100, 100), new Point(200, 200), new Point(300, 100)};
// Distort the origonal points
// Distort the original points
var distortedOriginalPoints =
OpenCVHelp.distortPoints(
List.of(originalPoints),

View File

@@ -0,0 +1,134 @@
/*
* Copyright (C) Photon Vision.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.photonvision.vision.pipeline;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.LinkedList;
import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.photonvision.common.configuration.NeuralNetworkModelManager;
public class ObjectDetectionTest {
private static LinkedList<String[]> passNames =
new LinkedList<String[]>(
java.util.Arrays.asList(
new String[] {"note-640-640-yolov5s.rknn", "note-640-640-yolov5s-labels.txt"},
new String[] {"object-640-640-yolov8n.rknn", "object-640-640-yolov8n-labels.txt"},
new String[] {
"example_1.2-640-640-yolov5l.rknn", "example_1.2-640-640-yolov5l-labels.txt"
},
new String[] {"demo_3.5-640-640-yolov8m.rknn", "demo_3.5-640-640-yolov8m-labels.txt"},
new String[] {"sample-640-640-yolov5x.rknn", "sample-640-640-yolov5x-labels.txt"},
new String[] {
"test_case-640-640-yolov8s.rknn", "test_case-640-640-yolov8s-labels.txt"
},
new String[] {
"model_ABC-640-640-yolov5n.rknn", "model_ABC-640-640-yolov5n-labels.txt"
},
new String[] {"my_model-640-640-yolov8x.rknn", "my_model-640-640-yolov8x-labels.txt"},
new String[] {"name_1.0-640-640-yolov5n.rknn", "name_1.0-640-640-yolov5n-labels.txt"},
new String[] {
"valid_name-640-640-yolov8s.rknn", "valid_name-640-640-yolov8s-labels.txt"
},
new String[] {
"test.model-640-640-yolov5l.rknn", "test.model-640-640-yolov5l-labels.txt"
},
new String[] {
"case1_test-640-640-yolov8m.rknn", "case1_test-640-640-yolov8m-labels.txt"
},
new String[] {"A123-640-640-yolov5x.rknn", "A123-640-640-yolov5x-labels.txt"},
new String[] {
"z_y_test.model-640-640-yolov8n.rknn", "z_y_test.model-640-640-yolov8n-labels.txt"
}));
private static LinkedList<String[]> parsedPassNames =
new LinkedList<String[]>(
java.util.Arrays.asList(
new String[] {"note", "640", "640", "yolov5s"},
new String[] {"object", "640", "640", "yolov8n"},
new String[] {"example_1.2", "640", "640", "yolov5l"},
new String[] {"demo_3.5", "640", "640", "yolov8m"},
new String[] {"sample", "640", "640", "yolov5x"},
new String[] {"test_case", "640", "640", "yolov8s"},
new String[] {"model_ABC", "640", "640", "yolov5n"},
new String[] {"my_model", "640", "640", "yolov8x"},
new String[] {"name_1.0", "640", "640", "yolov5n"},
new String[] {"valid_name", "640", "640", "yolov8s"},
new String[] {"test.model", "640", "640", "yolov5l"},
new String[] {"case1_test", "640", "640", "yolov8m"},
new String[] {"A123", "640", "640", "yolov5x"},
new String[] {"z_y_test.model", "640", "640", "yolov8n"}));
private static LinkedList<String[]> failNames =
new LinkedList<String[]>(
java.util.Arrays.asList(
new String[] {"note-yolov5s.rknn", "note-640-640-yolov5s-labels.txt"},
new String[] {"640-640-yolov8n.rknn", "object-640-640-yolov8n-labels.txt"},
new String[] {"example_1.2.rknn", "example_1.2-640-640-yolov5l-labels.txt"},
new String[] {"demo_3.5-640-yolov8m.rknn", "demo_3.5-640-640-yolov8m-labels.txt"},
new String[] {"sample-640.rknn", "sample-640-640-yolov5x-labels.txt"},
new String[] {"test_case.txt", "test_case-640-640-yolov8s-labels.txt"},
new String[] {"model_ABC.onnx", "model_ABC-640-640-yolov5n-labels.txt"},
new String[] {"my_model", "my_model-640-640-yolov8x-labels.txt"},
new String[] {"name_1.0-yolov5n.rknn", "wrong-labels.txt"},
new String[] {"", "valid_name-640-640-yolov8s-labels.txt"},
new String[] {null, "test.model-640-640-yolov5l-labels.txt"},
new String[] {"case1_test-640-640-yolov8m.rknn", null},
new String[] {"A123-640-640.rknn", "different-labels.txt"},
new String[] {"z_y_test.model", ""}));
// Test the model name validation for names that ought to pass
@ParameterizedTest
@MethodSource("verifyPassNameProvider")
public void testRKNNVerificationPass(String[] names) {
NeuralNetworkModelManager.verifyRKNNNames(names[0], names[1]);
}
// // Test the model name validation for names that ought to fail
@ParameterizedTest
@MethodSource("verifyFailNameProvider")
public void testRNNVerificationFail(String[] names) {
assertThrows(
IllegalArgumentException.class,
() -> NeuralNetworkModelManager.verifyRKNNNames(names[0], names[1]));
}
// Test the model name parsing
@ParameterizedTest
@MethodSource("parseNameProvider")
public void testRKNNNameParsing(String[] expected, String name) {
String[] parsed = NeuralNetworkModelManager.parseRKNNName(name);
assertArrayEquals(expected, parsed);
}
static Stream<Arguments> verifyPassNameProvider() {
return passNames.stream().map(array -> Arguments.of((Object) array));
}
static Stream<Arguments> verifyFailNameProvider() {
return failNames.stream().map(array -> Arguments.of((Object) array));
}
static Stream<Arguments> parseNameProvider() {
// return a stream of parsed pass names, and the first element of each pass name
return passNames.stream()
.map(name -> Arguments.of(parsedPassNames.get(passNames.indexOf(name)), name[0]));
}
}

View File

@@ -22,7 +22,15 @@
# SOFTWARE.
#
from .estimatedRobotPose import EstimatedRobotPose # noqa
from .packet import Packet # noqa
from .photonCamera import PhotonCamera # noqa
from .photonPoseEstimator import PhotonPoseEstimator, PoseStrategy # noqa
from .estimatedRobotPose import EstimatedRobotPose
from .packet import Packet
from .photonCamera import PhotonCamera
from .photonPoseEstimator import PhotonPoseEstimator, PoseStrategy
__all__ = (
"EstimatedRobotPose",
"Packet",
"PhotonCamera",
"PhotonPoseEstimator",
"PoseStrategy",
)

View File

@@ -3,3 +3,11 @@ from .simCameraProperties import SimCameraProperties
from .videoSimUtil import VideoSimUtil
from .visionSystemSim import VisionSystemSim
from .visionTargetSim import VisionTargetSim
__all__ = (
"PhotonCameraSim",
"SimCameraProperties",
"VideoSimUtil",
"VisionSystemSim",
"VisionTargetSim",
)

View File

@@ -1,6 +1,13 @@
# no one but us chickens
from .multiTargetPNPResult import MultiTargetPNPResult, PnpResult
from .photonPipelineResult import PhotonPipelineMetadata, PhotonPipelineResult
from .photonTrackedTarget import PhotonTrackedTarget
from .TargetCorner import TargetCorner
from .multiTargetPNPResult import MultiTargetPNPResult, PnpResult # noqa
from .photonPipelineResult import PhotonPipelineMetadata, PhotonPipelineResult # noqa
from .photonTrackedTarget import PhotonTrackedTarget # noqa
from .TargetCorner import TargetCorner # noqa
__all__ = (
"MultiTargetPNPResult",
"PnpResult",
"PhotonPipelineMetadata",
"PhotonPipelineResult",
"PhotonTrackedTarget",
"TargetCorner",
)

View File

@@ -59,11 +59,11 @@ setup(
version=versionString,
install_requires=[
"numpy~=2.1",
"wpilib<2026,>=2025.2.1",
"robotpy-wpimath<2026,>=2025.2.1",
"robotpy-apriltag<2026,>=2025.2.1",
"robotpy-cscore<2026,>=2025.2.1",
"pyntcore<2026,>=2025.2.1",
"wpilib<2026,>=2025.3.1",
"robotpy-wpimath<2026,>=2025.3.1",
"robotpy-apriltag<2026,>=2025.3.1",
"robotpy-cscore<2026,>=2025.3.1",
"pyntcore<2026,>=2025.3.1",
"opencv-python;platform_machine!='roborio'",
],
description=descriptionStr,

View File

@@ -43,11 +43,13 @@ import edu.wpi.first.networktables.PubSubOption;
import edu.wpi.first.networktables.StringSubscriber;
import edu.wpi.first.wpilibj.DriverStation;
import edu.wpi.first.wpilibj.Timer;
import edu.wpi.first.wpilibj.util.WPILibVersion;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import org.opencv.core.Core;
import org.photonvision.common.hardware.VisionLEDMode;
import org.photonvision.common.networktables.PacketSubscriber;
import org.photonvision.targeting.PhotonPipelineResult;
@@ -157,6 +159,76 @@ public class PhotonCamera implements AutoCloseable {
// HACK - start a TimeSyncServer, if we haven't yet.
TimeSyncSingleton.load();
// HACK - check if things are compatible
verifyDependencies();
}
public static void verifyDependencies() {
if (!WPILibVersion.Version.equals(PhotonVersion.wpilibTargetVersion)) {
String bfw =
"\n\n\n\n\n"
+ ">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\n"
+ ">>> !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
+ ">>> \n"
+ ">>> You are running an incompatible version \n"
+ ">>> of PhotonVision ! \n"
+ ">>> \n"
+ ">>> PhotonLib "
+ PhotonVersion.versionString
+ " is built for WPILib "
+ PhotonVersion.wpilibTargetVersion
+ "\n"
+ ">>> but you are using WPILib "
+ WPILibVersion.Version
+ ">>> \n"
+ ">>> This is neither tested nor supported. \n"
+ ">>> You MUST update PhotonVision, \n"
+ ">>> PhotonLib, or both. \n"
+ ">>> Verify the output of `./gradlew dependencies` \n"
+ ">>> \n"
+ ">>> Your code will now crash. \n"
+ ">>> We hope your day gets better. \n"
+ ">>> \n"
+ ">>> !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
+ ">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\n";
DriverStation.reportWarning(bfw, false);
DriverStation.reportError(bfw, false);
throw new UnsupportedOperationException(bfw);
}
if (!Core.VERSION.equals(PhotonVersion.opencvTargetVersion)) {
String bfw =
"\n\n\n\n\n"
+ ">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\n"
+ ">>> !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
+ ">>> \n"
+ ">>> You are running an incompatible version \n"
+ ">>> of PhotonVision ! \n"
+ ">>> \n"
+ ">>> PhotonLib "
+ PhotonVersion.versionString
+ " is built for OpenCV "
+ PhotonVersion.opencvTargetVersion
+ "\n"
+ ">>> but you are using OpenCV "
+ Core.VERSION
+ ">>> \n"
+ ">>> This is neither tested nor supported. \n"
+ ">>> You MUST update PhotonVision, \n"
+ ">>> PhotonLib, or both. \n"
+ ">>> Verify the output of `./gradlew dependencies` \n"
+ ">>> \n"
+ ">>> Your code will now crash. \n"
+ ">>> We hope your day gets better. \n"
+ ">>> \n"
+ ">>> !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
+ ">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\n";
DriverStation.reportWarning(bfw, false);
DriverStation.reportError(bfw, false);
throw new UnsupportedOperationException(bfw);
}
}
/**

View File

@@ -31,9 +31,12 @@ import edu.wpi.first.math.Matrix;
import edu.wpi.first.math.Pair;
import edu.wpi.first.math.geometry.Pose2d;
import edu.wpi.first.math.geometry.Pose3d;
import edu.wpi.first.math.geometry.Rotation2d;
import edu.wpi.first.math.geometry.Rotation3d;
import edu.wpi.first.math.geometry.Transform3d;
import edu.wpi.first.math.geometry.Translation2d;
import edu.wpi.first.math.geometry.Translation3d;
import edu.wpi.first.math.interpolation.TimeInterpolatableBuffer;
import edu.wpi.first.math.numbers.N1;
import edu.wpi.first.math.numbers.N3;
import edu.wpi.first.math.numbers.N8;
@@ -83,7 +86,18 @@ public class PhotonPoseEstimator {
* Use all visible tags to compute a single pose estimate. This runs on the RoboRIO, and can
* take a lot of time.
*/
MULTI_TAG_PNP_ON_RIO
MULTI_TAG_PNP_ON_RIO,
/**
* Use distance data from best visible tag to compute a Pose. This runs on the RoboRIO in order
* to access the robot's yaw heading, and MUST have addHeadingData called every frame so heading
* data is up-to-date.
*
* <p>Yields a Pose2d in estimatedRobotPose (0 for z, roll, pitch)
*
* <p>https://www.chiefdelphi.com/t/frc-6328-mechanical-advantage-2025-build-thread/477314/98
*/
PNP_DISTANCE_TRIG_SOLVE
}
private AprilTagFieldLayout fieldTags;
@@ -97,6 +111,9 @@ public class PhotonPoseEstimator {
protected double poseCacheTimestampSeconds = -1;
private final Set<Integer> reportedErrors = new HashSet<>();
private final TimeInterpolatableBuffer<Rotation2d> headingBuffer =
TimeInterpolatableBuffer.createBuffer(1.0);
/**
* Create a new PhotonPoseEstimator.
*
@@ -259,6 +276,30 @@ public class PhotonPoseEstimator {
setLastPose(new Pose3d(lastPose));
}
/**
* Add robot heading data to buffer. Must be called periodically for the
* <b>PNP_DISTANCE_TRIG_SOLVE</b> strategy.
*
* @param timestampSeconds timestamp of the robot heading data.
* @param heading Field-relative robot heading at given timestamp. Standard WPILIB field
* coordinates.
*/
public void addHeadingData(double timestampSeconds, Rotation3d heading) {
addHeadingData(timestampSeconds, heading.toRotation2d());
}
/**
* Add robot heading data to buffer. Must be called periodically for the
* <b>PNP_DISTANCE_TRIG_SOLVE</b> strategy.
*
* @param timestampSeconds timestamp of the robot heading data.
* @param heading Field-relative robot heading at given timestamp. Standard WPILIB field
* coordinates.
*/
public void addHeadingData(double timestampSeconds, Rotation2d heading) {
headingBuffer.addSample(timestampSeconds, heading);
}
/**
* @return The current transform from the center of the robot to the camera mount position
*/
@@ -286,8 +327,8 @@ public class PhotonPoseEstimator {
* <li>No targets were found in the pipeline results.
* </ul>
*
* Will report a warning if strategy is multi-tag-on-rio, but camera calibration data is not
* provided
* Will report a warning if strategy is multi-tag-on-rio because camera calibration data is not
* provided in this overload.
*
* @param cameraResult The latest pipeline result from the camera
* @return an {@link EstimatedRobotPose} with an estimated pose, timestamp, and targets used to
@@ -341,6 +382,20 @@ public class PhotonPoseEstimator {
return update(cameraResult, cameraMatrix, distCoeffs, this.primaryStrategy);
}
/**
* Internal convenience method for using a fallback strategy for update(). This should only be
* called after timestamp checks have been done by another update() overload.
*
* @param cameraResult The latest pipeline result from the camera
* @param strategy The pose strategy to use
* @return an {@link EstimatedRobotPose} with an estimated pose, timestamp, and targets used to
* create the estimate.
*/
private Optional<EstimatedRobotPose> update(
PhotonPipelineResult cameraResult, PoseStrategy strategy) {
return update(cameraResult, Optional.empty(), Optional.empty(), strategy);
}
private Optional<EstimatedRobotPose> update(
PhotonPipelineResult cameraResult,
Optional<Matrix<N3, N3>> cameraMatrix,
@@ -357,22 +412,10 @@ public class PhotonPoseEstimator {
yield closestToReferencePoseStrategy(cameraResult, referencePose);
}
case AVERAGE_BEST_TARGETS -> averageBestTargetsStrategy(cameraResult);
case MULTI_TAG_PNP_ON_RIO -> {
if (cameraMatrix.isEmpty()) {
DriverStation.reportWarning(
"Camera matrix is empty for multi-tag-on-rio",
Thread.currentThread().getStackTrace());
yield Optional.empty();
} else if (distCoeffs.isEmpty()) {
DriverStation.reportWarning(
"Camera matrix is empty for multi-tag-on-rio",
Thread.currentThread().getStackTrace());
yield Optional.empty();
} else {
yield multiTagOnRioStrategy(cameraResult, cameraMatrix, distCoeffs);
}
}
case MULTI_TAG_PNP_ON_RIO ->
multiTagOnRioStrategy(cameraResult, cameraMatrix, distCoeffs);
case MULTI_TAG_PNP_ON_COPROCESSOR -> multiTagOnCoprocStrategy(cameraResult);
case PNP_DISTANCE_TRIG_SOLVE -> pnpDistanceTrigSolveStrategy(cameraResult);
};
if (estimatedPose.isPresent()) {
@@ -382,42 +425,90 @@ public class PhotonPoseEstimator {
return estimatedPose;
}
private Optional<EstimatedRobotPose> multiTagOnCoprocStrategy(PhotonPipelineResult result) {
if (result.getMultiTagResult().isPresent()) {
var best_tf = result.getMultiTagResult().get().estimatedPose.best;
var best =
new Pose3d()
.plus(best_tf) // field-to-camera
.relativeTo(fieldTags.getOrigin())
.plus(robotToCamera.inverse()); // field-to-robot
return Optional.of(
new EstimatedRobotPose(
best,
result.getTimestampSeconds(),
result.getTargets(),
PoseStrategy.MULTI_TAG_PNP_ON_COPROCESSOR));
} else {
// We can never fall back on another multitag strategy
return update(result, Optional.empty(), Optional.empty(), this.multiTagFallbackStrategy);
private Optional<EstimatedRobotPose> pnpDistanceTrigSolveStrategy(PhotonPipelineResult result) {
PhotonTrackedTarget bestTarget = result.getBestTarget();
if (bestTarget == null) return Optional.empty();
var headingSampleOpt = headingBuffer.getSample(result.getTimestampSeconds());
if (headingSampleOpt.isEmpty()) {
return Optional.empty();
}
Rotation2d headingSample = headingSampleOpt.get();
Translation2d camToTagTranslation =
new Translation3d(
bestTarget.getBestCameraToTarget().getTranslation().getNorm(),
new Rotation3d(
0,
-Math.toRadians(bestTarget.getPitch()),
-Math.toRadians(bestTarget.getYaw())))
.rotateBy(robotToCamera.getRotation())
.toTranslation2d()
.rotateBy(headingSample);
var tagPoseOpt = fieldTags.getTagPose(bestTarget.getFiducialId());
if (tagPoseOpt.isEmpty()) {
return Optional.empty();
}
var tagPose2d = tagPoseOpt.get().toPose2d();
Translation2d fieldToCameraTranslation =
tagPose2d.getTranslation().plus(camToTagTranslation.unaryMinus());
Translation2d camToRobotTranslation =
robotToCamera.getTranslation().toTranslation2d().unaryMinus().rotateBy(headingSample);
Pose2d robotPose =
new Pose2d(fieldToCameraTranslation.plus(camToRobotTranslation), headingSample);
return Optional.of(
new EstimatedRobotPose(
new Pose3d(robotPose),
result.getTimestampSeconds(),
result.getTargets(),
PoseStrategy.PNP_DISTANCE_TRIG_SOLVE));
}
private Optional<EstimatedRobotPose> multiTagOnCoprocStrategy(PhotonPipelineResult result) {
if (result.getMultiTagResult().isEmpty()) {
return update(result, this.multiTagFallbackStrategy);
}
var best_tf = result.getMultiTagResult().get().estimatedPose.best;
var best =
new Pose3d()
.plus(best_tf) // field-to-camera
.relativeTo(fieldTags.getOrigin())
.plus(robotToCamera.inverse()); // field-to-robot
return Optional.of(
new EstimatedRobotPose(
best,
result.getTimestampSeconds(),
result.getTargets(),
PoseStrategy.MULTI_TAG_PNP_ON_COPROCESSOR));
}
private Optional<EstimatedRobotPose> multiTagOnRioStrategy(
PhotonPipelineResult result,
Optional<Matrix<N3, N3>> cameraMatrixOpt,
Optional<Matrix<N8, N1>> distCoeffsOpt) {
boolean hasCalibData = cameraMatrixOpt.isPresent() && distCoeffsOpt.isPresent();
// cannot run multitagPNP, use fallback strategy
if (!hasCalibData || result.getTargets().size() < 2) {
return update(result, cameraMatrixOpt, distCoeffsOpt, this.multiTagFallbackStrategy);
if (cameraMatrixOpt.isEmpty() || distCoeffsOpt.isEmpty()) {
DriverStation.reportWarning(
"No camera calibration data provided for multi-tag-on-rio",
Thread.currentThread().getStackTrace());
return update(result, this.multiTagFallbackStrategy);
}
if (result.getTargets().size() < 2) {
return update(result, this.multiTagFallbackStrategy);
}
var pnpResult =
VisionEstimation.estimateCamPosePNP(
cameraMatrixOpt.get(), distCoeffsOpt.get(), result.getTargets(), fieldTags, tagModel);
// try fallback strategy if solvePNP fails for some reason
if (!pnpResult.isPresent())
return update(result, cameraMatrixOpt, distCoeffsOpt, this.multiTagFallbackStrategy);
if (!pnpResult.isPresent()) return update(result, this.multiTagFallbackStrategy);
var best =
new Pose3d()
.plus(pnpResult.get().best) // field-to-camera

View File

@@ -172,9 +172,9 @@ public final class PhotonUtils {
* Estimates the pose of the robot in the field coordinate system, given the pose of the fiducial
* tag, the robot relative to the camera, and the target relative to the camera.
*
* @param fieldRelativeTagPose Pose3D the field relative pose of the target
* @param cameraToRobot Transform3D of the robot relative to the camera. Origin of the robot is
* defined as the center.
* @param fieldRelativeTagPose Pose3D the field relative pose of the target
* @param cameraToTarget Transform3D of the target relative to the camera, returned by
* PhotonVision
* @return Transform3d Robot position relative to the field

View File

@@ -160,11 +160,12 @@ public class SimCameraProperties {
if (!success) throw new IOException("Requested resolution not found in calibration");
}
public void setRandomSeed(long seed) {
public SimCameraProperties setRandomSeed(long seed) {
rand.setSeed(seed);
return this;
}
public void setCalibration(int resWidth, int resHeight, Rotation2d fovDiag) {
public SimCameraProperties setCalibration(int resWidth, int resHeight, Rotation2d fovDiag) {
if (fovDiag.getDegrees() < 1 || fovDiag.getDegrees() > 179) {
fovDiag = Rotation2d.fromDegrees(MathUtil.clamp(fovDiag.getDegrees(), 1, 179));
DriverStation.reportError(
@@ -189,9 +190,11 @@ public class SimCameraProperties {
// create camera intrinsics matrix
var camIntrinsics = MatBuilder.fill(Nat.N3(), Nat.N3(), fx, 0, cx, 0, fy, cy, 0, 0, 1);
setCalibration(resWidth, resHeight, camIntrinsics, distCoeff);
return this;
}
public void setCalibration(
public SimCameraProperties setCalibration(
int resWidth, int resHeight, Matrix<N3, N3> camIntrinsics, Matrix<N8, N1> distCoeffs) {
this.resWidth = resWidth;
this.resHeight = resHeight;
@@ -222,43 +225,54 @@ public class SimCameraProperties {
viewplanes.add(
new DMatrix3(translation3d.getX(), translation3d.getY(), translation3d.getZ()));
}
return this;
}
public void setCalibError(double avgErrorPx, double errorStdDevPx) {
public SimCameraProperties setCalibError(double avgErrorPx, double errorStdDevPx) {
this.avgErrorPx = avgErrorPx;
this.errorStdDevPx = errorStdDevPx;
return this;
}
/**
* @param fps The average frames per second the camera should process at. <b>Exposure time limits
* FPS if set!</b>
*/
public void setFPS(double fps) {
public SimCameraProperties setFPS(double fps) {
frameSpeedMs = Math.max(1000.0 / fps, exposureTimeMs);
return this;
}
/**
* @param exposureTimeMs The amount of time the "shutter" is open for one frame. Affects motion
* blur. <b>Frame speed(from FPS) is limited to this!</b>
*/
public void setExposureTimeMs(double exposureTimeMs) {
public SimCameraProperties setExposureTimeMs(double exposureTimeMs) {
this.exposureTimeMs = exposureTimeMs;
frameSpeedMs = Math.max(frameSpeedMs, exposureTimeMs);
return this;
}
/**
* @param avgLatencyMs The average latency (from image capture to data published) in milliseconds
* a frame should have
*/
public void setAvgLatencyMs(double avgLatencyMs) {
public SimCameraProperties setAvgLatencyMs(double avgLatencyMs) {
this.avgLatencyMs = avgLatencyMs;
return this;
}
/**
* @param latencyStdDevMs The standard deviation in milliseconds of the latency
*/
public void setLatencyStdDevMs(double latencyStdDevMs) {
public SimCameraProperties setLatencyStdDevMs(double latencyStdDevMs) {
this.latencyStdDevMs = latencyStdDevMs;
return this;
}
public int getResWidth() {

View File

@@ -27,10 +27,12 @@
#include <hal/FRCUsageReporting.h>
#include <net/TimeSyncServer.h>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
#include <WPILibVersion.h>
#include <frc/Errors.h>
#include <frc/RobotController.h>
#include <frc/Timer.h>
@@ -39,26 +41,81 @@
#include <wpi/json.h>
#include "PhotonVersion.h"
#include "opencv2/core/utility.hpp"
#include "photon/dataflow/structures/Packet.h"
inline constexpr std::string_view bfw =
"\n\n\n\n"
">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\n"
">>> !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
">>> \n"
">>> You are running an incompatible version \n"
">>> of PhotonVision on your coprocessor! \n"
">>> \n"
">>> This is neither tested nor supported. \n"
">>> You MUST update PhotonVision, \n"
">>> PhotonLib, or both. \n"
">>> \n"
">>> Your code will now crash. \n"
">>> We hope your day gets better. \n"
">>> \n"
">>> !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\n"
"\n\n";
inline void verifyDependencies() {
if (!(std::string_view{GetWPILibVersion()} ==
std::string_view{photon::PhotonVersion::wpilibTargetVersion})) {
std::string bfw =
"\n\n\n\n\n"
">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\n"
">>> !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
">>> \n"
">>> You are running an incompatible version \n"
">>> of PhotonVision ! \n"
">>> \n"
">>> PhotonLib ";
bfw += photon::PhotonVersion::versionString;
bfw += " is built for WPILib ";
bfw += photon::PhotonVersion::wpilibTargetVersion;
bfw +=
"\n"
">>> but you are using WPILib ";
bfw += GetWPILibVersion();
bfw +=
"\n>>> \n"
">>> This is neither tested nor supported. \n"
">>> You MUST update PhotonVision, \n"
">>> PhotonLib, or both. \n"
">>> Verify the output of `./gradlew dependencies` \n"
">>> \n"
">>> Your code will now crash. \n"
">>> We hope your day gets better. \n"
">>> \n"
">>> !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\n";
FRC_ReportWarning(bfw);
FRC_ReportError(frc::err::Error, bfw);
throw new std::runtime_error(std::string{bfw});
}
if (!(std::string_view{cv::getVersionString()} ==
std::string_view{photon::PhotonVersion::opencvTargetVersion})) {
std::string bfw =
"\n\n\n\n\n"
">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\n"
">>> !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
">>> \n"
">>> You are running an incompatible version \n"
">>> of PhotonVision ! \n"
">>> \n"
">>> PhotonLib ";
bfw += photon::PhotonVersion::versionString;
bfw += " is built for OpenCV ";
bfw += photon::PhotonVersion::opencvTargetVersion;
bfw +=
"\n"
">>> but you are using OpenCV ";
bfw += cv::getVersionString();
bfw +=
"\n>>> \n"
">>> This is neither tested nor supported. \n"
">>> You MUST update PhotonVision, \n"
">>> PhotonLib, or both. \n"
">>> Verify the output of `./gradlew dependencies` \n"
">>> \n"
">>> Your code will now crash. \n"
">>> We hope your day gets better. \n"
">>> \n"
">>> !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\n";
FRC_ReportWarning(bfw);
FRC_ReportError(frc::err::Error, bfw);
throw new std::runtime_error(std::string{bfw});
}
}
// bit of a hack -- start a TimeSync server on port 5810 (hard-coded). We want
// to avoid calling this from static initialization
@@ -125,6 +182,7 @@ PhotonCamera::PhotonCamera(nt::NetworkTableInstance instance,
topicNameSubscriber(instance, PHOTON_PREFIX, {.topicsOnly = true}),
path(rootTable->GetPath()),
cameraName(cameraName) {
verifyDependencies();
HAL_Report(HALUsageReporting::kResourceType_PhotonCamera, InstanceCount);
InstanceCount++;
@@ -310,7 +368,25 @@ void PhotonCamera::VerifyVersion() {
std::string remote_uuid{remote_uuid_json};
if (local_uuid != remote_uuid) {
FRC_ReportError(frc::warn::Warning, bfw);
constexpr std::string_view bfw =
"\n\n\n\n"
">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\n"
">>> !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
">>> \n"
">>> You are running an incompatible version \n"
">>> of PhotonVision on your coprocessor! \n"
">>> \n"
">>> This is neither tested nor supported. \n"
">>> You MUST update PhotonVision, \n"
">>> PhotonLib, or both. \n"
">>> \n"
">>> Your code will now crash. \n"
">>> We hope your day gets better. \n"
">>> \n"
">>> !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\n"
"\n\n";
FRC_ReportWarning(bfw);
std::string error_str = fmt::format(
"Photonlib version {} (message definition version {}) does not match "
"coprocessor version {} (message definition version {})!",

View File

@@ -350,18 +350,17 @@ frc::Pose3d detail::ToPose3d(const cv::Mat& tvec, const cv::Mat& rvec) {
std::optional<EstimatedRobotPose> PhotonPoseEstimator::MultiTagOnCoprocStrategy(
PhotonPipelineResult result) {
if (result.MultiTagResult()) {
const auto field2camera = result.MultiTagResult()->estimatedPose.best;
const auto fieldToRobot =
frc::Pose3d() + field2camera + m_robotToCamera.Inverse();
return photon::EstimatedRobotPose(fieldToRobot, result.GetTimestamp(),
result.GetTargets(),
MULTI_TAG_PNP_ON_COPROCESSOR);
if (!result.MultiTagResult()) {
return Update(result, this->multiTagFallbackStrategy);
}
return Update(result, std::nullopt, std::nullopt,
this->multiTagFallbackStrategy);
const auto field2camera = result.MultiTagResult()->estimatedPose.best;
const auto fieldToRobot =
frc::Pose3d() + field2camera + m_robotToCamera.Inverse();
return photon::EstimatedRobotPose(fieldToRobot, result.GetTimestamp(),
result.GetTargets(),
MULTI_TAG_PNP_ON_COPROCESSOR);
}
std::optional<EstimatedRobotPose> PhotonPoseEstimator::MultiTagOnRioStrategy(
@@ -370,19 +369,17 @@ std::optional<EstimatedRobotPose> PhotonPoseEstimator::MultiTagOnRioStrategy(
std::optional<PhotonCamera::DistortionMatrix> distCoeffs) {
using namespace frc;
// Need at least 2 targets
if (!result.HasTargets() || result.GetTargets().size() < 2) {
return Update(result, std::nullopt, std::nullopt,
this->multiTagFallbackStrategy);
}
if (!camMat || !distCoeffs) {
FRC_ReportError(frc::warn::Warning,
"No camera calibration data provided to "
"PhotonPoseEstimator::MultiTagOnRioStrategy!",
"");
return Update(result, std::nullopt, std::nullopt,
this->multiTagFallbackStrategy);
return Update(result, this->multiTagFallbackStrategy);
}
// Need at least 2 targets
if (!result.HasTargets() || result.GetTargets().size() < 2) {
return Update(result, this->multiTagFallbackStrategy);
}
auto const targets = result.GetTargets();
@@ -408,7 +405,7 @@ std::optional<EstimatedRobotPose> PhotonPoseEstimator::MultiTagOnRioStrategy(
// We should only do multi-tag if at least 2 tags (* 4 corners/tag)
if (imagePoints.size() < 8) {
return Update(result, camMat, distCoeffs, this->multiTagFallbackStrategy);
return Update(result, this->multiTagFallbackStrategy);
}
// Output mats for results

View File

@@ -24,13 +24,12 @@
#pragma once
#include <regex>
#include <string>
namespace photon {
namespace PhotonVersion {
extern const char* versionString;
extern const char* buildDate;
extern const bool isRelease;
extern const char* wpilibTargetVersion;
extern const char* opencvTargetVersion;
} // namespace PhotonVersion
} // namespace photon

View File

@@ -204,6 +204,21 @@ class PhotonPoseEstimator {
inline void InvalidatePoseCache() { poseCacheTimestamp = -1_s; }
/**
* Internal convenience method for using a fallback strategy for update().
* This should only be called after timestamp checks have been done by another
* update() overload.
*
* @param cameraResult The latest pipeline result from the camera
* @param strategy The pose strategy to use
* @return an EstimatedRobotPose with an estimated pose, timestamp, and
* targets used to create the estimate.
*/
std::optional<EstimatedRobotPose> Update(const PhotonPipelineResult& result,
PoseStrategy strategy) {
return Update(result, std::nullopt, std::nullopt, strategy);
}
std::optional<EstimatedRobotPose> Update(
const PhotonPipelineResult& result,
std::optional<PhotonCamera::CameraMatrix> cameraMatrixData,

View File

@@ -122,7 +122,7 @@ class PhotonCameraTest {
private static Stream<Arguments> testNtOffsets() {
return Stream.of(
// various initializaiton orders
// various initialization orders
Arguments.of(1, 10, 30, 30),
Arguments.of(10, 2, 30, 30),
Arguments.of(10, 10, 30, 30),

View File

@@ -24,6 +24,7 @@
package org.photonvision;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -41,6 +42,10 @@ import java.util.Optional;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.photonvision.PhotonPoseEstimator.PoseStrategy;
import org.photonvision.estimation.TargetModel;
import org.photonvision.simulation.PhotonCameraSim;
import org.photonvision.simulation.SimCameraProperties;
import org.photonvision.simulation.VisionTargetSim;
import org.photonvision.targeting.PhotonPipelineResult;
import org.photonvision.targeting.PhotonTrackedTarget;
import org.photonvision.targeting.TargetCorner;
@@ -488,6 +493,68 @@ class PhotonPoseEstimatorTest {
assertEquals(1, pose.getZ(), .01);
}
@Test
void pnpDistanceTrigSolve() {
PhotonCameraInjector cameraOne = new PhotonCameraInjector();
PhotonCameraSim cameraOneSim =
new PhotonCameraSim(cameraOne, SimCameraProperties.PERFECT_90DEG());
List<VisionTargetSim> simTargets =
aprilTags.getTags().stream()
.map((AprilTag x) -> new VisionTargetSim(x.pose, TargetModel.kAprilTag36h11, x.ID))
.toList();
/* Compound Rolled + Pitched + Yaw */
Transform3d compoundTestTransform =
new Transform3d(
-Units.inchesToMeters(12),
-Units.inchesToMeters(11),
3,
new Rotation3d(
Units.degreesToRadians(37), Units.degreesToRadians(6), Units.degreesToRadians(60)));
var estimator =
new PhotonPoseEstimator(
aprilTags, PoseStrategy.PNP_DISTANCE_TRIG_SOLVE, compoundTestTransform);
/* this is the real pose of the robot base we test against */
var realPose = new Pose3d(7.3, 4.42, 0, new Rotation3d(0, 0, 2.197));
PhotonPipelineResult result =
cameraOneSim.process(
1, realPose.transformBy(estimator.getRobotToCameraTransform()), simTargets);
estimator.addHeadingData(result.getTimestampSeconds(), realPose.getRotation().toRotation2d());
var estimatedPose = estimator.update(result);
var pose = estimatedPose.get().estimatedPose;
assertEquals(realPose.getX(), pose.getX(), .01);
assertEquals(realPose.getY(), pose.getY(), .01);
assertEquals(0.0, pose.getZ(), .01);
/* Straight on */
Transform3d straightOnTestTransform = new Transform3d(0, 0, 3, new Rotation3d(0, 0, 0));
estimator.setRobotToCameraTransform(straightOnTestTransform);
/* Pose to compare with */
realPose = new Pose3d(4.81, 2.38, 0, new Rotation3d(0, 0, 2.818));
result =
cameraOneSim.process(
1, realPose.transformBy(estimator.getRobotToCameraTransform()), simTargets);
estimator.addHeadingData(result.getTimestampSeconds(), realPose.getRotation().toRotation2d());
estimatedPose = estimator.update(result);
pose = estimatedPose.get().estimatedPose;
assertEquals(realPose.getX(), pose.getX(), .01);
assertEquals(realPose.getY(), pose.getY(), .01);
assertEquals(0.0, pose.getZ(), .01);
}
@Test
void cacheIsInvalidated() {
PhotonCameraInjector cameraOne = new PhotonCameraInjector();
@@ -645,6 +712,72 @@ class PhotonPoseEstimatorTest {
assertEquals(2.15, pose.getZ(), .01);
}
@Test
void testMultiTagOnRioFallback() {
PhotonCameraInjector camera = new PhotonCameraInjector();
camera.result =
new PhotonPipelineResult(
0,
11 * 1_000_000,
1_100_000,
1024,
List.of(
new PhotonTrackedTarget(
3.0,
-4.0,
9.0,
4.0,
0,
-1,
-1,
new Transform3d(new Translation3d(1, 2, 3), new Rotation3d(1, 2, 3)),
new Transform3d(new Translation3d(1, 2, 3), new Rotation3d(1, 2, 3)),
0.7,
List.of(
new TargetCorner(1, 2),
new TargetCorner(3, 4),
new TargetCorner(5, 6),
new TargetCorner(7, 8)),
List.of(
new TargetCorner(1, 2),
new TargetCorner(3, 4),
new TargetCorner(5, 6),
new TargetCorner(7, 8))),
new PhotonTrackedTarget(
3.0,
-4.0,
9.1,
6.7,
1,
-1,
-1,
new Transform3d(new Translation3d(4, 2, 3), new Rotation3d(0, 0, 0)),
new Transform3d(new Translation3d(4, 2, 3), new Rotation3d(1, 5, 3)),
0.3,
List.of(
new TargetCorner(1, 2),
new TargetCorner(3, 4),
new TargetCorner(5, 6),
new TargetCorner(7, 8)),
List.of(
new TargetCorner(1, 2),
new TargetCorner(3, 4),
new TargetCorner(5, 6),
new TargetCorner(7, 8)))));
PhotonPoseEstimator estimator =
new PhotonPoseEstimator(aprilTags, PoseStrategy.MULTI_TAG_PNP_ON_RIO, Transform3d.kZero);
estimator.setMultiTagFallbackStrategy(PoseStrategy.LOWEST_AMBIGUITY);
Optional<EstimatedRobotPose> estimatedPose = estimator.update(camera.result);
Pose3d pose = estimatedPose.get().estimatedPose;
// Make sure values match what we'd expect for the LOWEST_AMBIGUITY strategy
assertAll(
() -> assertEquals(11, estimatedPose.get().timestampSeconds),
() -> assertEquals(1, pose.getX(), 1e-9),
() -> assertEquals(3, pose.getY(), 1e-9),
() -> assertEquals(2, pose.getZ(), 1e-9));
}
private static class PhotonCameraInjector extends PhotonCamera {
public PhotonCameraInjector() {
super("Test");

View File

@@ -24,6 +24,8 @@
package org.photonvision;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Assertions;
@@ -56,4 +58,9 @@ public class PhotonVersionTest {
Assertions.assertFalse(versionMatches("", "v2021.1.6"));
Assertions.assertFalse(versionMatches("v2021.1.6", ""));
}
@Test
public void testNominalDeps() {
assertDoesNotThrow(PhotonCamera::verifyDependencies);
}
}

View File

@@ -419,6 +419,49 @@ TEST(PhotonPoseEstimatorTest, PoseCache) {
EXPECT_FALSE(estimatedPose);
}
TEST(PhotonPoseEstimatorTest, MultiTagOnRioFallback) {
photon::PhotonCamera cameraOne = photon::PhotonCamera("test");
std::vector<photon::PhotonTrackedTarget> targets{
photon::PhotonTrackedTarget{
3.0, -4.0, 9.0, 4.0, 0, -1, -1.f,
frc::Transform3d(frc::Translation3d(1_m, 2_m, 3_m),
frc::Rotation3d(1_rad, 2_rad, 3_rad)),
frc::Transform3d(frc::Translation3d(1_m, 2_m, 3_m),
frc::Rotation3d(1_rad, 2_rad, 3_rad)),
0.7, corners, detectedCorners},
photon::PhotonTrackedTarget{
3.0, -4.0, 9.1, 6.7, 1, -1, -1.f,
frc::Transform3d(frc::Translation3d(4_m, 2_m, 3_m),
frc::Rotation3d(0_rad, 0_rad, 0_rad)),
frc::Transform3d(frc::Translation3d(4_m, 2_m, 3_m),
frc::Rotation3d(0_rad, 0_rad, 0_rad)),
0.3, corners, detectedCorners}};
cameraOne.test = true;
cameraOne.testResult = {photon::PhotonPipelineResult{
photon::PhotonPipelineMetadata{0, 0, 2000, 1000}, targets, std::nullopt}};
cameraOne.testResult[0].SetReceiveTimestamp(units::second_t(11));
photon::PhotonPoseEstimator estimator(aprilTags, photon::LOWEST_AMBIGUITY,
frc::Transform3d{});
std::optional<photon::EstimatedRobotPose> estimatedPose;
for (const auto& result : cameraOne.GetAllUnreadResults()) {
estimatedPose = estimator.Update(result);
}
ASSERT_TRUE(estimatedPose);
frc::Pose3d pose = estimatedPose.value().estimatedPose;
// Make sure values match what we'd expect for the LOWEST_AMBIGUITY strategy
EXPECT_NEAR(11, units::unit_cast<double>(estimatedPose.value().timestamp),
.02);
EXPECT_NEAR(1, units::unit_cast<double>(pose.X()), .01);
EXPECT_NEAR(3, units::unit_cast<double>(pose.Y()), .01);
EXPECT_NEAR(2, units::unit_cast<double>(pose.Z()), .01);
}
TEST(PhotonPoseEstimatorTest, CopyResult) {
std::vector<photon::PhotonTrackedTarget> targets{};

View File

@@ -14,7 +14,7 @@ Like Rosmsg. But worse.
The code for a single type is split across 3 files. Let's look at PnpResult:
- [The struct definition](src/struct/pnpresult_struct.h): This is the data the object holds. Auto-generated. The data this object holds can be primitives or other, fully-deserialized types (like Vec2)
- [The user class](src/targeting/pnpresult_struct.h): This is the fully-deserialized PnpResult type. This contains extra functions users might need to expose like `Amgiguity`, or other computed helper things.
- [The user class](src/targeting/pnpresult_struct.h): This is the fully-deserialized PnpResult type. This contains extra functions users might need to expose like `Ambiguity`, or other computed helper things.
- [The serde interface](src/serde/pnpresult_struct.h): This is a template specialization for converting the user class to/from bytes
## Prior art

View File

@@ -29,7 +29,6 @@ import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Optional;
import java.util.regex.Pattern;
import javax.imageio.ImageIO;
import org.apache.commons.io.FileUtils;
import org.opencv.core.Mat;
@@ -432,7 +431,7 @@ public class RequestHandler {
var tempPath2 = Files.createTempFile("photonvision-kernelogs", ".txt");
// In the command below:
// dmesg = output all kernel logs since current boot
// cat /var/log/kern.log = output all kernal logs since first boot
// cat /var/log/kern.log = output all kernel logs since first boot
shell.executeBashCommand(
"journalctl -u photonvision.service > "
+ tempPath.toAbsolutePath()
@@ -467,8 +466,8 @@ public class RequestHandler {
}
} catch (IOException e) {
ctx.status(500);
ctx.result("There was an error while exporting journactl logs");
logger.error("There was an error while exporting journactl logs", e);
ctx.result("There was an error while exporting journalctl logs");
logger.error("There was an error while exporting journalctl logs", e);
}
}
@@ -573,24 +572,9 @@ public class RequestHandler {
}
// verify naming convention
// this check will need to be modified if different model types are added
Pattern modelPattern = Pattern.compile("^[a-zA-Z0-9]+-\\d+-\\d+-yolov[58][a-z]*\\.rknn$");
Pattern labelsPattern =
Pattern.compile("^[a-zA-Z0-9]+-\\d+-\\d+-yolov[58][a-z]*-labels\\.txt$");
if (!modelPattern.matcher(modelFile.filename()).matches()
|| !labelsPattern.matcher(labelsFile.filename()).matches()
|| !(modelFile
.filename()
.substring(0, modelFile.filename().indexOf("-"))
.equals(labelsFile.filename().substring(0, labelsFile.filename().indexOf("-"))))) {
ctx.status(400);
ctx.result("The uploaded files were not named correctly.");
logger.error("The uploaded object detection model files were not named correctly.");
return;
}
// throws IllegalArgumentException if the model name is invalid
NeuralNetworkModelManager.verifyRKNNNames(modelFile.filename(), labelsFile.filename());
// TODO move into neural network manager

View File

@@ -164,7 +164,7 @@ public class PhotonPipelineResult
/**
* Returns the estimated time the frame was taken, in the Time Sync Server's time base (nt::Now).
* This is calculated using the estiamted offset between Time Sync Server time and local time. The
* This is calculated using the estimated offset between Time Sync Server time and local time. The
* robot shall run a server, so the offset shall be 0.
*
* @return The timestamp in seconds

View File

@@ -1,7 +1,7 @@
plugins {
id "cpp"
id "google-test-test-suite"
id "edu.wpi.first.GradleRIO" version "2025.2.1"
id "edu.wpi.first.GradleRIO" version "2025.3.1"
}
repositories {
@@ -11,8 +11,8 @@ repositories {
wpi.maven.useLocal = false
wpi.maven.useDevelopment = false
wpi.versions.wpilibVersion = "2025.2.1"
wpi.versions.wpimathVersion = "2025.2.1"
wpi.versions.wpilibVersion = "2025.3.1"
wpi.versions.wpimathVersion = "2025.3.1"
// Define my targets (RoboRIO) and artifacts (deployable files)
// This is added by GradleRIO's backing project DeployUtils.

View File

@@ -1,7 +1,7 @@
plugins {
id "cpp"
id "google-test-test-suite"
id "edu.wpi.first.GradleRIO" version "2025.2.1"
id "edu.wpi.first.GradleRIO" version "2025.3.1"
}
repositories {
@@ -11,8 +11,8 @@ repositories {
wpi.maven.useLocal = false
wpi.maven.useDevelopment = false
wpi.versions.wpilibVersion = "2025.2.1"
wpi.versions.wpimathVersion = "2025.2.1"
wpi.versions.wpilibVersion = "2025.3.1"
wpi.versions.wpimathVersion = "2025.3.1"
// Define my targets (RoboRIO) and artifacts (deployable files)
// This is added by GradleRIO's backing project DeployUtils.

View File

@@ -1,7 +1,7 @@
plugins {
id "cpp"
id "google-test-test-suite"
id "edu.wpi.first.GradleRIO" version "2025.2.1"
id "edu.wpi.first.GradleRIO" version "2025.3.1"
}
repositories {
@@ -11,8 +11,8 @@ repositories {
wpi.maven.useLocal = false
wpi.maven.useDevelopment = false
wpi.versions.wpilibVersion = "2025.2.1"
wpi.versions.wpimathVersion = "2025.2.1"
wpi.versions.wpilibVersion = "2025.3.1"
wpi.versions.wpimathVersion = "2025.3.1"
// Define my targets (RoboRIO) and artifacts (deployable files)
// This is added by GradleRIO's backing project DeployUtils.

View File

@@ -1,6 +1,6 @@
plugins {
id "java"
id "edu.wpi.first.GradleRIO" version "2025.2.1"
id "edu.wpi.first.GradleRIO" version "2025.3.1"
}
sourceCompatibility = JavaVersion.VERSION_17
@@ -13,8 +13,8 @@ repositories {
}
wpi.maven.useDevelopment = true
wpi.versions.wpilibVersion = "2025.2.1"
wpi.versions.wpimathVersion = "2025.2.1"
wpi.versions.wpilibVersion = "2025.3.1"
wpi.versions.wpimathVersion = "2025.3.1"
// Define my targets (RoboRIO) and artifacts (deployable files)

View File

@@ -143,8 +143,8 @@ public class SwerveDrive {
}
/**
* Command the swerve modules to the desired states. Velocites exceeding the maximum speed will be
* desaturated (while preserving the ratios between modules).
* Command the swerve modules to the desired states. Velocities exceeding the maximum speed will
* be desaturated (while preserving the ratios between modules).
*
* @param openLoop If swerve modules should use feedforward only and ignore velocity feedback
* control.

View File

@@ -1,6 +1,6 @@
plugins {
id "java"
id "edu.wpi.first.GradleRIO" version "2025.2.1"
id "edu.wpi.first.GradleRIO" version "2025.3.1"
}
sourceCompatibility = JavaVersion.VERSION_17
@@ -9,8 +9,8 @@ targetCompatibility = JavaVersion.VERSION_17
def ROBOT_MAIN_CLASS = "frc.robot.Main"
wpi.maven.useDevelopment = true
wpi.versions.wpilibVersion = "2025.2.1"
wpi.versions.wpimathVersion = "2025.2.1"
wpi.versions.wpilibVersion = "2025.3.1"
wpi.versions.wpimathVersion = "2025.3.1"
// Define my targets (RoboRIO) and artifacts (deployable files)

View File

@@ -143,8 +143,8 @@ public class SwerveDrive {
}
/**
* Command the swerve modules to the desired states. Velocites exceeding the maximum speed will be
* desaturated (while preserving the ratios between modules).
* Command the swerve modules to the desired states. Velocities exceeding the maximum speed will
* be desaturated (while preserving the ratios between modules).
*
* @param openLoop If swerve modules should use feedforward only and ignore velocity feedback
* control.

View File

@@ -1,6 +1,6 @@
plugins {
id "java"
id "edu.wpi.first.GradleRIO" version "2025.2.1"
id "edu.wpi.first.GradleRIO" version "2025.3.1"
}
sourceCompatibility = JavaVersion.VERSION_17
@@ -9,8 +9,8 @@ targetCompatibility = JavaVersion.VERSION_17
def ROBOT_MAIN_CLASS = "frc.robot.Main"
wpi.maven.useDevelopment = true
wpi.versions.wpilibVersion = "2025.2.1"
wpi.versions.wpimathVersion = "2025.2.1"
wpi.versions.wpilibVersion = "2025.3.1"
wpi.versions.wpimathVersion = "2025.3.1"
// Define my targets (RoboRIO) and artifacts (deployable files)

View File

@@ -143,8 +143,8 @@ public class SwerveDrive {
}
/**
* Command the swerve modules to the desired states. Velocites exceeding the maximum speed will be
* desaturated (while preserving the ratios between modules).
* Command the swerve modules to the desired states. Velocities exceeding the maximum speed will
* be desaturated (while preserving the ratios between modules).
*
* @param openLoop If swerve modules should use feedforward only and ignore velocity feedback
* control.

View File

@@ -6,7 +6,7 @@
[tool.robotpy]
# Version of robotpy this project depends on
robotpy_version = "2025.2.1"
robotpy_version = "2025.3.1"
# Which extra RobotPy components should be installed
# -> equivalent to `pip install robotpy[extra1, ...]

View File

@@ -6,7 +6,7 @@
[tool.robotpy]
# Version of robotpy this project depends on
robotpy_version = "2025.2.1"
robotpy_version = "2025.3.1"
# Which extra RobotPy components should be installed
# -> equivalent to `pip install robotpy[extra1, ...]

View File

@@ -6,7 +6,7 @@
[tool.robotpy]
# Version of robotpy this project depends on
robotpy_version = "2025.2.1"
robotpy_version = "2025.3.1"
# Which extra RobotPy components should be installed
# -> equivalent to `pip install robotpy[extra1, ...]

View File

@@ -30,5 +30,9 @@ namespace photon {
const char* versionString = "${version}";
const char* buildDate = "${date}";
const bool isRelease = strncmp(dev_, versionString, strlen(dev_)) != 0;
// Versions of dependant libraries
const char* wpilibTargetVersion = "${wpilibVersion}";
const char* opencvTargetVersion = "${opencvVersion}";
}
}

View File

@@ -38,6 +38,10 @@ public final class PhotonVersion {
public static final String buildDate = "${date}";
public static final boolean isRelease = !versionString.startsWith("dev");
// Versions of dependant libraries
public static final String wpilibTargetVersion = "${wpilibVersion}";
public static final String opencvTargetVersion = "${opencvVersion}";
public static final boolean versionMatches(String other) {
String c = versionString;
Pattern p = Pattern.compile("v[0-9]+.[0-9]+.[0-9]+");

View File

@@ -34,7 +34,11 @@ gradle.allprojects {
String date = DateTimeFormatter.ofPattern("yyyy-M-d hh:mm:ss").format(LocalDateTime.now())
File versionFileOut = new File(path.toAbsolutePath().toString())
versionFileOut.delete()
def read = versionFileIn.text.replace('${version}', version).replace('${date}', date)
def read = versionFileIn.text.replace('${version}', version)
.replace('${date}', date)
.replace('${wpilibVersion}', wpilibVersion)
// Note that OpenCV is usually {VERSION}-{some suffix}, we just want the first bit
.replace('${opencvVersion}', openCVversion.split("-").first())
if (!versionFileOut.parentFile.exists()) versionFileOut.parentFile.mkdirs()
if (!versionFileOut.exists()) versionFileOut.createNewFile()
versionFileOut.write(read)