Compare commits

...

71 Commits

Author SHA1 Message Date
Matt Morley
ccbd46be1a Release processed Focus mat to not leak, cache, and fix cvmat refcounting (#2356) 2026-02-08 21:17:43 +00:00
Watermilan412
994dfe77fa Fix Arducam OV9782 Exposure Changing After Reboot (#2355) 2026-02-07 18:24:54 +00:00
Devon Doyle
5a87e4c738 Add calibration info tooltip (#2350) 2026-02-03 05:41:24 +00:00
Alan Everett
284e818e74 Fix dimmable LED off-state, topic names, PWM flicker, brightness update outside kDefault, indefinite blinking, and blinking reliability (#2337) 2026-02-02 21:11:34 -08:00
Gold856
f4b30da6b3 Dynamically import echarts and three.js (#2349) 2026-02-02 08:06:48 -08:00
Sam Freund
798b01c3a6 Copy old configs before testing (#2348)
## Description

Presently, any tests using our old configs happen in place. This is
problematic, as it changes the files themselves. This means that anyone
running the tests will cause unintentional modifications, which stand a
chance of being committed and merged into main. We want these old
database files to remain untouched, thus we copy them to a temp
directory prior to running our tests.

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] The description documents the _what_ and _why_, including events
that led to this PR
- [ ] 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 all settings going back to the previous seasons's last release
(seasons end after champs ends)
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added
2026-02-02 03:30:37 +00:00
Sam Freund
23392f8d46 Add missing early-return to legacy ML model load (#2347)
## Description

Previously, when attempting to load a model that did not follow the old
naming pattern that was in the models directory, we caused PV to crash
due to a missing early return (reproducable on this branch with the
extra bonus ML model and missing label file):

```
    [2026-02-01 17:35:15] [Config - NeuralNetworkModelManager] [ERROR] Model properties are null. This could mean the config for model /home/matth/photonvision/test-resources/old_configs/2025.3.1-old-nnmm/models/iCauseProblems.rknn was unable to be found in the database. Trying legacy...
    [2026-02-01 17:35:15] [Config - NeuralNetworkModelManager] [ERROR] Failed to translate legacy model filename to properties: /home/matth/photonvision/test-resources/old_configs/2025.3.1-old-nnmm/models/iCauseProblems.rknn: /home/matth/photonvision/test-resources/old_configs/2025.3.1-old-nnmm/models/iCauseProblems-labels.txt

SQLConfigTest > testLoadNewNNMM() FAILED
    java.lang.NullPointerException: Cannot invoke "org.photonvision.common.configuration.NeuralNetworkModelsSettings$ModelProperties.toString()" because "properties" is null
        at org.photonvision.common.configuration.NeuralNetworkModelManager.loadModel(NeuralNetworkModelManager.java:342)
        at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.accept(ForEachOps.java:183)
```

This PR fixes it by adding the missing early-return, and adding unit
tests to make sure we handle this.

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] The description documents the _what_ and _why_, including events
that led to this PR
- [ ] 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 all settings going back to the previous seasons's last release
(seasons end after champs ends)
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [x] If this PR addresses a bug, a regression test for it is added

---------

Co-authored-by: Matt M <matthew.morley.ca@gmail.com>
2026-02-02 02:09:13 +00:00
Wave Robotics - 2826
09e6d45e77 Allow configuring maximum target count (#2338) 2026-02-01 16:19:13 -08:00
Craig Schardt
77457219c7 Remove unused commands from the custom hardware configuration documentation (#2343)
## Description

#2255 introduced a new, cross-platform method for monitoring hardware
and removed the custom shell commands that had been used previously.
This PR updates the documentation to reflect the removal of those
commands from hardwareConfig.json.

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] The description documents the _what_ and _why_, including events
that led to this PR
- [ ] 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 all settings going back to the previous seasons's last release
(seasons end after champs ends)
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added

---------

Co-authored-by: Gold856 <117957790+Gold856@users.noreply.github.com>
2026-01-29 23:40:09 -06:00
Bruce Kahn
da88867c60 Doc: Add a warning about versions 2.0.2 and later of the Raspberry Pi Imager not working properly (#2340)
## Description

FRC Team 6413 ran into problems trying to write the 2026.1.1 image using
the Raspberry Pi Imager tool. After some investigation we discovered a
bug was introduced in v2.0.2 of the imager tool which prevents it from
writing the PhotonVision images to any SD card on any Windows 10 or
Windows 11 computer.

Attempting to write a custom image (.img and .img.xz) results in the
following error dialog appearing as soon as the write action is
confirmed:

<img width="684" height="483" alt="photonvisionBurnFailure202"
src="https://github.com/user-attachments/assets/5c802ef0-75f9-4056-ac1f-760dcd3605bf"
/>

Versions 2.0.0 and earlier will successfully write the image files to
the selected SD card.

## Meta

Merge checklist:
- [X] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [X] The description documents the _what_ and _why_, including events
that led to this PR
- [ ] 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 all settings going back to the previous seasons's last release
(seasons end after champs ends)
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added
2026-01-28 19:45:30 -05:00
Matt Morley
a39844328d Add 2025.3.1 backwards-compat to ML models (#2331) 2026-01-28 12:54:35 -08:00
John Fogarty
1b5f4fa802 Update RubikPi Collab Notebook with working Device (#2339)
The RubikPi collab notebook included a device for creating a quantized
model that was deprecated by the Qualcomm team. I've included a separate
cell with a command to check available devices and updated the command
to a known working state.
2026-01-28 07:11:25 +00:00
Cooper Bouchard
7cc22e52ea Add FRC 2026 game piece detection models (#2332)
Added a model for detecting Rebuilt's FUEL game piece for versions of
the Orange Pi 5 and the Rubik Pi 3, which lowers the bar for less
experienced FRC teams to use object detection on their robots. Also
updated the documentation of object detection to reference the new
models.

The original model weights were trained by FRC team 2826 and were
published on this [Chief Delphi
thread](https://www.chiefdelphi.com/t/introducing-wave-robotics-yolov11-model-for-rebuilt/512701).

The dataset used for RKNN quantization is the [Fuel V3
dataset](https://universe.roboflow.com/frcroboraiders/frc-2026-fuel-sbrdk/dataset/3)
provided by
[frcroboraiders](https://universe.roboflow.com/frcroboraiders) on
Roboflow Universe.
2026-01-27 23:47:54 -06:00
Chris Gerth
49629afe9b not 2024 anymore (#2328)
Update apriltag field language in the docs to be year agnostic
2026-01-27 04:42:22 +00:00
Gold856
ae74b171aa Check POST request status before displaying success (#2336) 2026-01-26 20:11:25 -08:00
Gold856
cfd5773e7c Update README (#2333) 2026-01-26 06:25:59 +00:00
Gold856
c348f0e3ba Update PR template (#2334) 2026-01-26 05:56:17 +00:00
Matt Morley
4139566514 Add OV9281 AE startup quirk (#1814)
## Description

In https://github.com/PhotonVision/photonvision/issues/1771, we
discovered that the OV9281 on Linux seems to sometimes ignore our
exposure requests on first boot if we're in manual mode. Cycling the
camera's auto exposure with pauses in-between seems to fix it, which is
what this PR does.

Tested by [Team 2881 on
Discord](https://discord.com/channels/725836368059826228/725846784131203222/1465098110765105456);
thanks for helping us test this fix out!

## Meta

Closes https://github.com/PhotonVision/photonvision/issues/1771

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] 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

---------

Co-authored-by: Gold856 <117957790+Gold856@users.noreply.github.com>
2026-01-26 00:11:14 +00:00
Sam Freund
b7a0fad54c Set max target limit to 50 (#2320)
## 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`. -->

closes #2318
closes #2319 

For the 2027 game, teams might want to detect more than 10 results.
Therefore, we're increasing the limit.

We also ran into an issue with our sim, where a user can create too many
objects and cause an overflow. We implement that same limit of 50
targets here.

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] 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 v2025.3.2
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added
2026-01-21 16:44:49 -08:00
Gold856
e73420d62a Clarify when estimate methods return empty (#2323)
## Description

Clarifies when estimate methods return an empty optional. This also
fixes some parity issues with Constrained SolvePnP not checking the
optional returned by the heading buffer.

Resolves #2322.

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] 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 v2025.3.2
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added
2026-01-21 23:13:47 +00:00
Jay Ticku
12f74423d9 Added clarifying details to wiring part of docs for wiring regulator to coprocessor (#2293)
## Description
Added specific details to the wiring section of the photonvision docs
for two wiring methods for connecting a power regulator to a
coprocessor. This aims to help prevent any possible misunderstandings of
how to wire a regulator to a coprocessor.

## 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 v2025.3.2
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added

Co-authored-by: Matt Morley <matthew.morley.ca@gmail.com>
2026-01-20 05:36:38 +00:00
Sam Freund
6c9a142622 Propagate changes after modifying OD model (#2299)
## 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`. -->

This PR adjusts how model modification is handled. Any updates to
metadata, or deletion is handled in the frontend after sending data to
the backend. Any creation of models occurs in the backend, and we push
an update to the frontend.

This PR also fixes a typo in PhotonUtils which meant we hit the wrong
endpoint when an IP was passed into the status check.

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] 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 v2025.3.2
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added
2026-01-19 11:58:23 -08:00
William H Wang
149c214897 Chore: Updated documentation for Fx/Fy units. (#2314)
https://mrcal.secretsauce.net/lensmodels.html

Updated the labeling for camera calibration results with MRCAL.
2026-01-19 08:46:59 +00:00
Gold856
a952bab4c9 Remove strict WPILib version requirement (#2307) 2026-01-18 18:56:07 +00:00
Space646
bc208bca85 Add comment documenting typo copied from COCO docs (#2253)
Signed-off-by: Jade Turner <spacey-sooty@proton.me>
Co-authored-by: Jade Turner <spacey-sooty@proton.me>
Co-authored-by: Matt Morley <matthew.morley.ca@gmail.com>
2026-01-19 01:50:56 +08:00
Matt Morley
dbd6eea4e9 Cache requested calibration format outside of state store (#2310)
## Description

On main, this pop-up lists the wrong resolution. The index in the state
is getting updated out from underneath us. The easiest solution I have
is to just cache this number.

<img width="739" height="1051" alt="image"
src="https://github.com/user-attachments/assets/a4383ea0-aa1b-4446-87f1-ece8c5ea9ad5"
/>

<img width="873" height="1116" alt="image"
src="https://github.com/user-attachments/assets/7da4cd60-f8a6-4ac6-8bc6-9f0d5c165fee"
/>

Not sure of a great way to test this, since it requires the backend
taking calibration snapshots

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] 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 v2025.3.2
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added
2026-01-18 16:04:20 +00:00
David Vo
afb73b3918 refactor: separate build, test, and deploy in Python workflow (#2308)
_Test what you build, deploy what you test._

This refactors the Python CI workflow to wait for _all_ tests to pass
before publishing photonlibpy to PyPI.

- build-python-examples reuses the built wheel, removes redundant builds
- Simplify run.sh to not rebuild wheel since it's already installed
2026-01-17 15:46:19 +00:00
David Vo
9011e285d2 Stop shipping unit tests in photonlibpy wheel (#2309) 2026-01-17 14:27:29 +00:00
Matt Morley
8a141904a6 WPILib 2026.2.1 (#2306)
## Description

Find-and-replace 2026.1.1 -> 2026.2.1. This gets us 2026 field layouts
[among other
things](https://github.com/wpilibsuite/allwpilib/releases/tag/v2026.2.1)

<!-- 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 v2025.3.2
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added
2026-01-17 00:55:30 +00:00
Sam Freund
121433fd90 Loosely pin numpy to 2.3 (#2303) 2026-01-15 16:41:39 -06:00
Sam Freund
22567dea74 Smoketest after uploading images (#2294) 2026-01-14 18:39:21 -08:00
Rikhil Chilka
ba4eb621c3 Rknn numpy fix (#2298) 2026-01-14 15:22:35 -06:00
David Vo
43608c5113 fix: simplify version regex and fix rc tag handling (#2296) 2026-01-13 14:17:36 +00:00
Sam Freund
021053d43e Fix parentheses in workflow (#2292)
Mismatched parenthesis meant releases were never created
2026-01-12 17:59:28 -06:00
Sam Freund
3b57125d96 Update to 2026 (#2288) 2026-01-12 14:00:11 -08:00
Jordan McMichael
e088050902 Fix rendering of tags 30, 31, and 32 in simulated camera streams (#2291) 2026-01-12 18:53:03 +00:00
Vasista Vovveti
224ce46f14 Make the latency/fps setting per camera instead of global (#2260)
There's a new `low latency mode` switch on the input tab.

This replaces use_new_cscore_frametime and makes it per pipeline.

<img width="684" height="535" alt="image"
src="https://github.com/user-attachments/assets/a7ba8bc0-69b6-44f3-83e3-9b88d8219dfa"
/>

The default behavior is still to block for new frames (ie, preserve old
behavior)

---------

Co-authored-by: Matt Morley <matthew.morley.ca@gmail.com>
2026-01-12 05:21:50 +00:00
DeltaDizzy
12a8b88b4a 2026 Test Mode Images (#2285)
Adds test image from wpilib 2026 field test images

Co-authored-by: Matt <matthew.morley.ca@gmail.com>
2026-01-11 13:48:10 -06:00
Gold856
e4749a3ea9 Fix javadoc warnings (#2266)
Persuant to #1093, I added as many docstrings as I could, at least for
things I knew about. Some of the classes I just suppressed the Javadoc
warnings in because they aren't particularly useful to document. This
gets us down to less than 100 Javadoc warnings in total. Docs for core
classes on the C++ side were also added for parity.
2026-01-11 19:25:49 +00:00
Gold856
a5dc9ec0d6 Open up pose estimator strategy methods (#2252) 2026-01-11 18:58:53 +00:00
Devon Doyle
8e9fe38477 Device metrics and control reorganization (#2144) 2026-01-11 10:25:49 -08:00
Matt Morley
5aefb2957d Bump IMAGE_VERSION to v2026.1.1 (#2286) 2026-01-11 09:30:32 -08:00
Craig Schardt
1bedadde97 Fix OSHI spamming failures to console on Windows (#2282)
## Description

Monitoring CPU Temperature on Windows is challenging because most
vendors don't publish this data to WMI. As a work-around, OSHI tries to
use
[LibreHardwareMonitor](https://github.com/LibreHardwareMonitor/LibreHardwareMonitor)
via
[jLibreHardwareMonitor](https://github.com/pandalxb/jLibreHardwareMonitor).
If the temperature isn't found in WMI and jLibreHardwareMonitor isn't
present, OSHI issues warnings every time `getCpuTemperature()` is
called. This clogs the console with useless information when running on
Windows and makes testing difficult.

We could include jLibreHardwareMonitor as a dependency for our Windows
jar, but LibreHardwareMonitor installs Winring0.sys, which is a kernel
level driver with an unfixed severe vulnerability. Windows defender
flags Winring0 as a vulnuratble driver and blocks it from installing.
Rather than messing with it, this PR prevents Windows systems from
calling the `getCpuTemperature()` method in OSHI.

Fixes #2280

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] The description documents the _what_ and _why_

---------

Co-authored-by: Gold856 <117957790+Gold856@users.noreply.github.com>
2026-01-08 00:22:17 -05:00
Sam Freund
de8905ee10 Bump actions for Node 24 (#2274)
## Description

This bumps our actions to versions that use Node 24, instead of 20. Node
20 [enters EOL in April 2026](https://endoflife.date/nodejs).

This PR also includes various cleanups that should speed up CI, and make
it less complicated. This includes removing the architecture field from
setup-java, as it detects the native architecture.

We also upload our Gradle dependencies for charting in GitHub, this
helps us keep track of what we're using, and if we need to upgrade.

Finally, we bump the version of our image, to fix issues with the Rubik
Pi fan among other reasons.

continuation of #2194

supercedes #2276

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] 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 v2025.3.2
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added
2026-01-07 17:03:36 -05:00
Charlotte Wilson
6ca7354542 Build Python examples during CI run (#2273)
Adds `photonlib-python-examples` to CI build run to make sure that
Python builds cleanly against that PR/commit.
2026-01-07 16:14:39 -05:00
Sam Freund
3f9e2a9fa8 Force reload after restart and switch URL after IP change (#2278)
## Description

Forces a reload after restarting PhotonVision, restarting the
coprocessor, performing an offline update, or nuking the install. We
wait until we are reconnected to the coprocessor to reload, this is
accomplished by the addition of a status API endpoint.

This is being implemented due to issues experienced when the webpage is
not updated (particularly during offline updates).

---

Using the same statusCheck, we also wait until a new IP is available,
then change to it, after changing our static IP.

---

closes #2169
closes #903

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] 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 v2025.3.2
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added
2026-01-07 02:53:05 -05:00
Mark McBride
e993cca067 Updating wiring doc to include RUBIK Pi instructions (#2281) 2026-01-07 06:58:25 +00:00
Alan Everett
a780c9dc41 Add macOS mrcal support (#2264) 2026-01-06 07:11:33 -08:00
Matt Morley
a2b06bd3dd Use WPILib Artifactory mirror instead of Maven Central (#2277)
## Description

By hitting WPIlib's Artifactory Maven Central mirror instead of Maven
Central, we should reduce the number of 403 flakes we see during
workflow runs. This also removes jogamp from the repository list, as
jogamp dependencies were removed in #1926.

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] 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 v2025.3.2
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added

---------

Co-authored-by: samfreund <samf.236@proton.me>
2026-01-04 22:23:42 -05:00
Matt Morley
db0667f1dc Use forked WPILib tool plugin (#2120)
## Description

Update to use our fork of the WPILib tool plugin. WPILib has indicated
that it won't be maintained past 2026, so we're forking it under our
org. We also fixed a bug that results in native libraries for multiple
platforms being included in a jar when only the native libraries for the
platform being built should be included.

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] 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 v2025.3.2
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added

---------

Co-authored-by: Gold856 <117957790+Gold856@users.noreply.github.com>
Co-authored-by: samfreund <samf.236@proton.me>
2026-01-04 17:22:58 -05:00
Gold856
eb2b681f24 [ci] Restore mypy checks (#2128)
## Description

Fixed in https://github.com/robotpy/mostrobotpy/pull/191. Closes #1968.

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] 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 v2025.3.2
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added
2026-01-03 05:03:45 +00:00
Sam Freund
8db1341b79 Download artifacts for testing examples (#2270)
Instead of building photon-lib for our examples, wait until it's built
elsewhere then use that to run the examples. This means we don't have to
build photon-lib twice.
2026-01-02 21:09:30 -06:00
Sam Freund
940c3430b8 Fix opengraph embed (#2265)
The opengraph embed requires the image to be accessible at the linked
location. As you can see by clicking on this link,
https://photonvision.org/images/PhotonVision-Icon-BG_2.png, it is not
accessible. This is because we need to configure a public directory when
we publish with vite, as the subfolders will not inherently be
published.

This PR adds a public directory and moves all our images there. I also
moved the video to a video directory rather than it being in images.
2026-01-03 02:42:59 +00:00
Sam Freund
70fed3535e Bump various versions in actions (#2194)
Bumps various versions in actions, as well as ensuring consistency
across workflows wrt versions used. One of the key reasons is ensuring
that we're up to date and consistent (previously having used 22.04 and
24.04), also since 3.11 is EOL.
2026-01-02 19:49:34 -06:00
Craig Schardt
5409573f0d Rewrite system monitoring to use OSHI (#2255)
## Description

[OSHI](https://github.com/oshi/oshi) is a free (MIT license) JNA-based
library for accessing hardware and system performance information. This
PR includes a re-write of the metrics monitoring code to be based on
OSHI. The original intent was to gain access to data about network
traffic for addition to the Settings tab. An additional benefit is that
collecting the data is now around two orders of magnitude (or more)
faster!

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] 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
- [x] If this PR touches configuration, this is backwards compatible
with settings back to v2025.3.2
- [x] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added

---------

Co-authored-by: samfreund <samf.236@proton.me>
2026-01-02 17:55:12 -05:00
Sam Freund
6b9599d68a Remove etcher from recommended flashers (#2271)
Etcher isn't being maintained very well, so we don't want teams to use
it.
2026-01-02 04:00:39 +00:00
Gold856
00ca5e06ba Remove unused scripting system (#2269) 2026-01-01 16:35:14 -08:00
Graham
d30ae6cc27 Refactor website (#2243)
## Description

Switches to Vue for easier maintenance (pre-built in CI). Changes UI to
look a little nicer
<img width="1920" height="1080" alt="image"
src="https://github.com/user-attachments/assets/c5933326-c391-4d5c-8b3c-d3eeaa11a2f9"
/>
<img width="1920" height="1080" alt="image"
src="https://github.com/user-attachments/assets/d933c8d0-e5a0-40c0-bc80-0c4c8a4cc4f0"
/>
<img width="1920" height="1080" alt="image"
src="https://github.com/user-attachments/assets/3d8a652b-bd49-4147-a269-140e79fd4164"
/>
<img width="1021" height="1352" alt="image"
src="https://github.com/user-attachments/assets/545a9c02-541c-4a34-b81f-587f21bc682f"
/>
<img width="957" height="1196" alt="image"
src="https://github.com/user-attachments/assets/0dfd8080-0ffe-48c5-8fe0-11177f95dfc9"
/>


## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] 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 v2025.3.2
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added

---------

Co-authored-by: Gold856 <117957790+Gold856@users.noreply.github.com>
2025-12-30 01:44:08 -05:00
Sam Freund
80d3efe00e Add NT controlled framerate limiter (#2257)
Adds a method to lower the speed of a pipeline over NT, primarily to
reduce power consumption.
2025-12-29 23:01:10 -06:00
Matt Morley
fddff5dbca Show board outliers in calibration info card (#1267) 2025-12-26 21:20:36 -05:00
Vishnu Bharath
235e601cbc Fix typo in selecting-hardware documentation (#2256)
Corrects a spelling error in the hardware selection documentation
(`dependant` → `dependent`). No functional changes.
2025-12-25 23:08:03 +00:00
Sam Freund
3fbda538f4 qdl instructions for flashing rubik pi 3 (#2254) 2025-12-20 15:27:09 -06:00
Craig Schardt
9d587d5746 Log fewer errors (#2246)
## Description

We currently log some things with ERROR status and include stack traces
for events that are typical behavior. This pollutes the logs and makes
it harder to track down real errors.

This PR changes the way that some events are logged:
* missing configs in the database are logged as [INFO] without the
exception stack trace.
* skip parsing NPU usage when the command is blank so that it doesn't
throw a NumberFormatException.
* log warn instead of error for unsupported NN backends (added by
@samfreund)
* skip warn when we don't add a model, only debug when we add it (added
by @samfreund)

Before:
```
Oct 22 20:56:26 photonvision java[831]: [2024-10-22 20:56:26] [Config - SqlConfigProvider] [ERROR] Could not deserialize apriltag layout! Loading defaults: Provided empty string for class edu.wpi.first.apriltag.AprilTagFieldLayout
Oct 22 20:56:26 photonvision java[831]: [2024-10-22 20:56:26] [Config - SqlConfigProvider] [ERROR] org.eclipse.jetty.io.EofException: Provided empty string for class edu.wpi.first.apriltag.AprilTagFieldLayout
Oct 22 20:56:26 photonvision java[831]:         at org.photonvision.common.util.file.JacksonUtils.deserialize(JacksonUtils.java:136)
Oct 22 20:56:26 photonvision java[831]:         at org.photonvision.common.configuration.SqlConfigProvider.load(SqlConfigProvider.java:298)
Oct 22 20:56:26 photonvision java[831]:         at org.photonvision.common.configuration.ConfigManager.load(ConfigManager.java:198)
Oct 22 20:56:26 photonvision java[831]:         at org.photonvision.Main.main(Main.java:290)
```

After:
```
Dec 15 22:29:09 photonvision java[662]: [2025-12-15 22:29:09] [Config - SqlConfigProvider] [INFO] Missing AprilTag Field Layout in database. Loading defaults
```

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] 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 v2025.3.2
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added

---------

Co-authored-by: samfreund <samf.236@proton.me>
Co-authored-by: Gold856 <117957790+Gold856@users.noreply.github.com>
2025-12-20 02:35:18 +00:00
Craig Schardt
7a88487131 Wait for NetworkManager on first call to getAllInterfaces (#2240)
## Description

On the RubikPi3, PhotonVision starts before NetworkManager has fully
initialized. This causes it to fail to identify the network interfaces
available on the board, which leads to problems with configuring and
controling networking from the UI. The failure can be detected by the
call to `nmcli` returning an [exit status of
8](https://networkmanager.dev/docs/api/latest/nmcli.html#exit_status),
which means "NetworkManager is not running."

This PR retries the call to nmcli every 0.5 seconds until the exit
status does not equal 8, or a maximum of 10 attempts have been made. The
retry only occurs the first time `getAllInterfaces()` is called.
Subsequent calls to this method will only make one attempt to avoid
locking up the program if networking isn't responding as expected.

In my testing on the RubikPi3, the code has to retry for less than 2
seconds in order to get a valid response from NetworkManager.

The need for this is greatly reduced by
https://github.com/PhotonVision/photon-image-modifier/pull/114, but this
code adds an additional layer of robustness against slow network
startup.

Closes #2212

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] 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 v2025.3.2
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added

---------

Co-authored-by: samfreund <samf.236@proton.me>
2025-12-19 19:55:48 -06:00
Gold856
0e33aef8f9 Check if returned NetworkInterface is null before attempting to get MAC address (#2245)
## Description

On boot, the interface might not be fully configured yet, and the
returned NetworkInterface will be null. This would cause an NPE to be
thrown when attempting to get the MAC address from the interface. This
checks if the interface is null and silently returns an empty string if
it is, which is okay if the networking is being managed because if that
interface is broken, there's bigger problems. Fixes #2244.

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] 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 v2025.3.2
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added
2025-12-19 02:40:32 +00:00
Charlotte
6a349aa8bb Discourage use of NT APIs (#2182)
## Description

As per the linked issue, raw NT APIs are old and mostly obsolete, this
PR adds user-facing notifications of that.

Closes #1828

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] The description documents the _what_ and _why_
- [x] 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 v2025.3.2
2025-12-19 01:47:10 +00:00
Sam Freund
50f2285937 Integrate smoketests into image build (#2248)
Runs the smoketest on each image after we build it in CI. This helps
ensure that we check every image that we build has the requisite JNI
libraries, and that they can be loaded. This is needed cause we've been
breaking that, and our current smoketests don't actually test all our
libraries since some are gated behind platform checks.
2025-12-17 10:16:21 -06:00
Sam Freund
90acd19361 Bump libcamera and mrcal versions (#2251)
The libcamera and mrcal jars weren't being included/put in the correct
place. This pr bumps the version of the dependencies to rectify that
issue.
2025-12-17 01:50:39 +00:00
Tyler Veness
5362e0cc6c [ci] Upgrade to wpiformat 2025.79 (#2250)
wpiformat now skips files with extensions known to be binary files.
2025-12-16 18:22:15 -05:00
Jade
ff69ddc247 Fix default settings for deploy (#2242) 2025-12-14 08:55:38 -08:00
Sam Freund
07affd4fe3 RobotPy 2026 beta (#2237)
## Description

This updates RobotPy and our Python examples to the 2026 beta.

## Meta

Merge checklist:
- [x] Pull Request title is [short, imperative
summary](https://cbea.ms/git-commit/) of proposed changes
- [x] 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 v2025.3.2
- [ ] If this PR touches pipeline settings or anything related to data
exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added
2025-12-12 07:52:17 +00:00
289 changed files with 10649 additions and 4777 deletions

View File

@@ -1,18 +1,18 @@
## Description
<!-- What changed? Why? (the code + comments should speak for itself on the "how") -->
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. -->
Include fun testing screenshots or a cool video, to collect test evidence in a place where we can later reference it. Including proof this change was tested makes reviewing easier, helps us make sure we tested all our edge cases, and helps provide context for the future.
<!-- Any issues this pull request closes or pull requests this supersedes should be linked with `Closes #issuenumber`. -->
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_
- [ ] The description documents the _what_ and _why_, including events that led to this PR
- [ ] 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 v2025.3.2
- [ ] If this PR touches configuration, this is backwards compatible with all settings going back to the previous seasons's last release (seasons end after champs ends)
- [ ] If this PR touches pipeline settings or anything related to data exchange, the frontend typing is updated
- [ ] If this PR addresses a bug, a regression test for it is added

View File

@@ -10,7 +10,7 @@ concurrency:
cancel-in-progress: true
env:
IMAGE_VERSION: v2026.0.6
IMAGE_VERSION: v2026.1.2
jobs:
@@ -18,8 +18,9 @@ jobs:
name: "Validation"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: gradle/actions/wrapper-validation@v4
- uses: actions/checkout@v6
- uses: gradle/actions/wrapper-validation@v5
build-examples:
strategy:
@@ -27,24 +28,25 @@ jobs:
matrix:
include:
- os: windows-2022
architecture: x64
artifact-name: Win64
- os: macos-14
architecture: aarch64
- os: ubuntu-22.04
artifact-name: macOS
- os: ubuntu-24.04
artifact-name: Linux
name: "Photonlib - Build Examples - ${{ matrix.os }}"
runs-on: ${{ matrix.os }}
needs: [validation]
needs: [build-photonlib-host, build-photonlib-docker]
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Fetch tags
run: git fetch --tags --force
- name: Install Java 17
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: 17
distribution: temurin
@@ -54,10 +56,31 @@ jobs:
run: |
find ~/.gradle/cache/ -name *roborio-academic* -exec rm -rf {} +
du -h . | sort -h
if: matrix.os == 'ubuntu-22.04'
# Need to publish to maven local first, so that C++ sim can pick it up
- name: Publish photonlib to maven local
run: ./gradlew photon-targeting:publishtomavenlocal photon-lib:publishtomavenlocal -x check
if: matrix.os == 'ubuntu-24.04'
# Download prebuilt photonlib artifacts
- uses: actions/download-artifact@v7
with:
name: maven-${{ matrix.artifact-name }}
- uses: actions/download-artifact@v7
with:
name: maven-Athena
- name: Move to maven local
run: |
mkdir -p ~/.m2/repository/
mv maven/org ~/.m2/repository/
- name: Copy vendordeps
shell: bash
run: |
for vendordep_folder in photonlib-*-examples/*/; do
# Remove trailing slash for cross-platform compatibility
vendordep_folder="${vendordep_folder%/}"
# Filter for projects only
if [ -e "$vendordep_folder/build.gradle" ]; then
mkdir -p "$vendordep_folder/vendordeps/"
cp vendordeps/photonlib-json-1.0.json "$vendordep_folder/vendordeps/"
fi
done
- name: Build Java examples
working-directory: photonlib-java-examples
run: |
@@ -68,20 +91,21 @@ jobs:
run: |
./gradlew build
./gradlew clean
playwright-tests:
name: "Playwright E2E tests"
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
needs: [validation]
steps:
# Checkout code.
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Fetch tags
run: git fetch --tags --force
- name: Install Java 17
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: 17
distribution: temurin
@@ -90,7 +114,7 @@ jobs:
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 22
- name: Setup tests
@@ -103,7 +127,7 @@ jobs:
- name: Run Playwright tests
working-directory: photon-client
run: pnpm test
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report
@@ -111,18 +135,18 @@ jobs:
retention-days: 30
build-gradle:
name: "Gradle Build"
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
needs: [validation]
steps:
# Checkout code.
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Fetch tags
run: git fetch --tags --force
- name: Install Java 17
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: 17
distribution: temurin
@@ -131,7 +155,7 @@ jobs:
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 22
- name: Gradle Build
@@ -140,12 +164,12 @@ jobs:
run: ./gradlew test jacocoTestReport --stacktrace
build-offline-docs:
name: "Build Offline Docs"
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: '3.11'
python-version: 3.14
- name: Install graphviz
run: |
sudo apt-get update
@@ -160,22 +184,22 @@ jobs:
working-directory: docs
run: |
make html
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
with:
name: built-docs
path: docs/build/html
build-photonlib-vendorjson:
name: "Build Vendor JSON"
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
needs: [validation]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install Java 17
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: 17
distribution: temurin
@@ -190,40 +214,37 @@ jobs:
mv photon-lib/build/generated/vendordeps/photonlib.json photon-lib/build/generated/vendordeps/photonlib-$(git describe --tags --match=v*).json
# Upload it here so it shows up in releases
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
with:
name: photonlib-vendor-json
path: photon-lib/build/generated/vendordeps/photonlib-*.json
build-photonlib-host:
env:
MACOSX_DEPLOYMENT_TARGET: 13
MACOSX_DEPLOYMENT_TARGET: 14
strategy:
fail-fast: false
matrix:
include:
- os: windows-2022
artifact-name: Win64
architecture: x64
- os: macos-14
- os: macos-26
artifact-name: macOS
architecture: aarch64
- os: ubuntu-22.04
- os: ubuntu-24.04
artifact-name: Linux
name: "Photonlib - Build Host - ${{ matrix.artifact-name }}"
runs-on: ${{ matrix.os }}
needs: [validation]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install Java 17
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: 17
distribution: temurin
architecture: ${{ matrix.architecture }}
- run: git fetch --tags --force
- run: ./gradlew photon-targeting:build photon-lib:build
name: Build with Gradle
@@ -234,7 +255,7 @@ jobs:
if: github.event_name == 'push' && github.repository_owner == 'photonvision'
# Copy artifacts to build/outputs/maven
- run: ./gradlew photon-lib:publish photon-targeting:publish -PcopyOfflineArtifacts
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
with:
name: maven-${{ matrix.artifact-name }}
path: build/outputs
@@ -254,12 +275,12 @@ jobs:
artifact-name: Aarch64
build-options: "-Ponlylinuxarm64"
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
container: ${{ matrix.container }}
name: "Photonlib - Build Docker - ${{ matrix.artifact-name }}"
needs: [validation]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Config Git
@@ -275,7 +296,7 @@ jobs:
if: github.event_name == 'push' && github.repository_owner == 'photonvision'
# Copy artifacts to build/outputs/maven
- run: ./gradlew photon-lib:publish photon-targeting:publish -PcopyOfflineArtifacts ${{ matrix.build-options }}
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
with:
name: maven-${{ matrix.artifact-name }}
path: build/outputs
@@ -283,14 +304,14 @@ jobs:
combine:
name: Combine
needs: [build-photonlib-docker, build-photonlib-host]
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- run: git fetch --tags --force
# download all maven-* artifacts to outputs/
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v7
with:
merge-multiple: true
path: output
@@ -300,12 +321,87 @@ jobs:
name: ZIP stuff up
working-directory: output
- run: ls output
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
with:
name: photonlib-offline
path: output/*.zip
build-package:
build-package-linux:
needs: [build-gradle, build-offline-docs]
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-24.04
artifact-name: Linux
arch-override: linuxx64
- os: ubuntu-24.04
artifact-name: LinuxArm64
arch-override: linuxarm64
runs-on: ${{ matrix.os }}
name: "Build fat JAR - ${{ matrix.artifact-name }}"
steps: &build-package-steps
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install Java 17
uses: actions/setup-java@v5
with:
java-version: 17
distribution: temurin
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: photon-client/pnpm-lock.yaml
- name: Install Arm64 Toolchain
run: ./gradlew installArm64Toolchain
if: ${{ (matrix.artifact-name) == 'LinuxArm64' }}
- uses: actions/download-artifact@v7
with:
name: built-docs
path: photon-server/src/main/resources/web/docs
- run: ./gradlew photon-targeting:jar photon-server:shadowJar -PArchOverride=${{ matrix.arch-override }}
if: ${{ (matrix.arch-override != 'none') }}
- run: ./gradlew photon-server:shadowJar
if: ${{ (matrix.arch-override == 'none') }}
- uses: actions/upload-artifact@v6
with:
name: jar-${{ matrix.artifact-name }}
path: photon-server/build/libs
- uses: actions/upload-artifact@v6
with:
name: photon-targeting_jar-${{ matrix.artifact-name }}
path: photon-targeting/build/libs
build-package-macos:
needs: [build-gradle, build-offline-docs]
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
artifact-name: macOSArm
arch-override: macarm64
- os: macos-latest
artifact-name: macOS
arch-override: macx64
runs-on: ${{ matrix.os }}
name: "Build fat JAR - ${{ matrix.artifact-name }}"
steps: *build-package-steps
build-package-windows:
needs: [build-gradle, build-offline-docs]
strategy:
@@ -314,94 +410,37 @@ jobs:
include:
- os: windows-latest
artifact-name: Win64
architecture: x64
arch-override: winx64
- os: macos-latest
artifact-name: macOS
architecture: x64
arch-override: macx64
- os: macos-latest
artifact-name: macOSArm
architecture: x64
arch-override: macarm64
- os: ubuntu-22.04
artifact-name: Linux
architecture: x64
arch-override: linuxx64
- os: ubuntu-22.04
artifact-name: LinuxArm64
architecture: x64
arch-override: linuxarm64
runs-on: ${{ matrix.os }}
name: "Build fat JAR - ${{ matrix.artifact-name }}"
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Java 17
uses: actions/setup-java@v4
with:
java-version: 17
distribution: temurin
architecture: ${{ matrix.architecture }}
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: photon-client/pnpm-lock.yaml
- name: Install Arm64 Toolchain
run: ./gradlew installArm64Toolchain
if: ${{ (matrix.artifact-name) == 'LinuxArm64' }}
- uses: actions/download-artifact@v4
with:
name: built-docs
path: photon-server/src/main/resources/web/docs
- run: ./gradlew photon-targeting:jar photon-server:shadowJar -PArchOverride=${{ matrix.arch-override }}
if: ${{ (matrix.arch-override != 'none') }}
- run: ./gradlew photon-server:shadowJar
if: ${{ (matrix.arch-override == 'none') }}
- uses: actions/upload-artifact@v4
with:
name: jar-${{ matrix.artifact-name }}
path: photon-server/build/libs
- uses: actions/upload-artifact@v4
with:
name: photon-targeting_jar-${{ matrix.artifact-name }}
path: photon-targeting/build/libs
steps: *build-package-steps
run-smoketest-native:
needs: [build-package]
needs: [build-package-linux, build-package-macos, build-package-windows]
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
- os: ubuntu-24.04
artifact-name: jar-Linux
extraOpts: -Djdk.lang.Process.launchMechanism=vfork
- os: windows-latest
artifact-name: jar-Win64
extraOpts: ""
- os: macos-latest
artifact-name: jar-macOS
architecture: x64
runs-on: ${{ matrix.os }}
steps:
- name: Install Java 17
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: 17
distribution: temurin
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v7
with:
name: ${{ matrix.artifact-name }}
- run: java -jar ${{ matrix.extraOpts }} *.jar --smoketest
@@ -409,43 +448,8 @@ jobs:
- run: ls *.jar | %{ Write-Host "Running $($_.Name)"; Start-Process "java" -ArgumentList "-jar `"$($_.FullName)`" --smoketest" -NoNewWindow -Wait; break }
if: ${{ (matrix.os) == 'windows-latest' }}
run-smoketest-chroot:
needs: [build-package]
strategy:
fail-fast: false
matrix:
include:
- image_suffix: RaspberryPi
image_url: https://github.com/PhotonVision/photon-image-modifier/releases/download/$IMAGE_VERSION/photonvision_raspi.img.xz
- image_suffix: rubikpi3
image_url: https://github.com/PhotonVision/photon-image-modifier/releases/download/$IMAGE_VERSION/photonvision_rubikpi3.tar.xz
root_location: 'offset=569376768'
- image_suffix: orangepi5
image_url: https://github.com/PhotonVision/photon-image-modifier/releases/download/$IMAGE_VERSION/photonvision_opi5.img.xz
runs-on: ubuntu-24.04-arm
name: smoketest-${{ matrix.image_suffix }}
steps:
- uses: actions/download-artifact@v4
with:
name: jar-LinuxArm64
- uses: photonvision/photon-image-runner@HEAD
name: Run photon smoketest
id: generate_image
with:
image_url: ${{ matrix.image_url }}
root_location: ${{ matrix.root_location || 'partition=2' }}
# our image better have java installed already
commands: |
java -jar *.jar --smoketest
build-image:
needs: [build-package]
if: ${{ github.event_name != 'pull_request' }}
needs: [build-package-linux]
strategy:
fail-fast: false
@@ -454,66 +458,79 @@ jobs:
- os: ubuntu-24.04-arm
artifact-name: LinuxArm64
image_suffix: RaspberryPi
plat_override: LINUX_RASPBIAN64
image_url: https://github.com/PhotonVision/photon-image-modifier/releases/download/$IMAGE_VERSION/photonvision_raspi.img.xz
minimum_free_mb: 100
- os: ubuntu-24.04-arm
artifact-name: LinuxArm64
image_suffix: limelight2
plat_override: LINUX_RASPBIAN64
image_url: https://github.com/PhotonVision/photon-image-modifier/releases/download/$IMAGE_VERSION/photonvision_limelight.img.xz
minimum_free_mb: 100
- os: ubuntu-24.04-arm
artifact-name: LinuxArm64
image_suffix: limelight3
plat_override: LINUX_RASPBIAN64
image_url: https://github.com/PhotonVision/photon-image-modifier/releases/download/$IMAGE_VERSION/photonvision_limelight3.img.xz
minimum_free_mb: 100
- os: ubuntu-24.04-arm
artifact-name: LinuxArm64
image_suffix: limelight3G
plat_override: LINUX_RASPBIAN64
image_url: https://github.com/PhotonVision/photon-image-modifier/releases/download/$IMAGE_VERSION/photonvision_limelight3g.img.xz
minimum_free_mb: 100
- os: ubuntu-24.04-arm
artifact-name: LinuxArm64
image_suffix: limelight4
plat_override: LINUX_RASPBIAN64
image_url: https://github.com/PhotonVision/photon-image-modifier/releases/download/$IMAGE_VERSION/photonvision_limelight4.img.xz
minimum_free_mb: 100
- os: ubuntu-24.04-arm
artifact-name: LinuxArm64
image_suffix: luma_p1
plat_override: LINUX_RASPBIAN64
image_url: https://github.com/PhotonVision/photon-image-modifier/releases/download/$IMAGE_VERSION/photonvision_luma_p1.img.xz
minimum_free_mb: 100
- os: ubuntu-24.04-arm
artifact-name: LinuxArm64
image_suffix: orangepi5
plat_override: LINUX_RK3588_64
image_url: https://github.com/PhotonVision/photon-image-modifier/releases/download/$IMAGE_VERSION/photonvision_opi5.img.xz
minimum_free_mb: 1024
- os: ubuntu-24.04-arm
artifact-name: LinuxArm64
image_suffix: orangepi5b
plat_override: LINUX_RK3588_64
image_url: https://github.com/PhotonVision/photon-image-modifier/releases/download/$IMAGE_VERSION/photonvision_opi5b.img.xz
minimum_free_mb: 1024
- os: ubuntu-24.04-arm
artifact-name: LinuxArm64
image_suffix: orangepi5plus
plat_override: LINUX_RK3588_64
image_url: https://github.com/PhotonVision/photon-image-modifier/releases/download/$IMAGE_VERSION/photonvision_opi5plus.img.xz
minimum_free_mb: 1024
- os: ubuntu-24.04-arm
artifact-name: LinuxArm64
image_suffix: orangepi5pro
plat_override: LINUX_RK3588_64
image_url: https://github.com/PhotonVision/photon-image-modifier/releases/download/$IMAGE_VERSION/photonvision_opi5pro.img.xz
minimum_free_mb: 1024
- os: ubuntu-24.04-arm
artifact-name: LinuxArm64
image_suffix: orangepi5max
plat_override: LINUX_RK3588_64
image_url: https://github.com/PhotonVision/photon-image-modifier/releases/download/$IMAGE_VERSION/photonvision_opi5max.img.xz
minimum_free_mb: 1024
- os: ubuntu-24.04-arm
artifact-name: LinuxArm64
image_suffix: rock5c
plat_override: LINUX_RK3588_64
image_url: https://github.com/PhotonVision/photon-image-modifier/releases/download/$IMAGE_VERSION/photonvision_rock5c.img.xz
minimum_free_mb: 1024
- os: ubuntu-24.04-arm
artifact-name: LinuxArm64
image_suffix: rubikpi3
plat_override: LINUX_QCS6490
image_url: https://github.com/PhotonVision/photon-image-modifier/releases/download/$IMAGE_VERSION/photonvision_rubikpi3.tar.xz
minimum_free_mb: 1024
root_location: 'offset=569376768'
@@ -524,10 +541,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v7
with:
name: jar-${{ matrix.artifact-name }}
- uses: photonvision/photon-image-runner@HEAD
@@ -538,59 +555,81 @@ jobs:
minimum_free_mb: ${{ matrix.minimum_free_mb }}
root_location: ${{ matrix.root_location || 'partition=2' }}
shrink_image: ${{ matrix.shrink_image || 'yes' }}
commands: |
chmod +x scripts/armrunner.sh
./scripts/armrunner.sh
commands: ./scripts/armrunner.sh
- name: Compress image
# Compress the standard images
if: ${{ ! startsWith(matrix.image_suffix, 'rubik') }}
run: |
set -ex
new_jar=$(realpath $(find . -name photonvision\*-linuxarm64.jar))
new_image_name=$(basename "${new_jar/.jar/_${{ matrix.image_suffix }}.img}")
sudo mv ${{ steps.generate_image.outputs.image }} $new_image_name
sudo xz -T 0 -v $new_image_name
sudo xz -T 0 -kv $new_image_name
echo "smoketest_image_loc=${new_image_name}" >> $GITHUB_ENV
- name: Tar built image
- name: Tar built image (Rubik)
# Build the RubikPi3-specific tar file
if: ${{ startsWith(matrix.image_suffix, 'rubik') }}
run: |
set -ex
new_jar=$(realpath $(find . -name photonvision\*-linuxarm64.jar))
new_image_name=$(basename "${new_jar/.jar/_${{ matrix.image_suffix }}.img}")
tardir=$(basename "${new_jar/.jar/_${{ matrix.image_suffix }}.img}")
imagedir=$(dirname ${{ steps.generate_image.outputs.image }})
tardir=${new_image_name}
sudo mkdir --parents ${tardir}
sudo mv ${imagedir}/* ${tardir}/
sudo tar -I 'xz -T0' -cf ${new_image_name}.tar.xz ${tardir} --checkpoint=10000 --checkpoint-action=echo='%T'
sudo cp ${imagedir}/* ${tardir}/
sudo tar -I 'xz -T0' -cf ${tardir}.tar.xz ${tardir} --checkpoint=10000 --checkpoint-action=echo='%T'
# Point smoketest to the old image
echo "smoketest_image_loc=${{ steps.generate_image.outputs.image }}" >> $GITHUB_ENV
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
name: Upload image
with:
name: image-${{ matrix.image_suffix }}
path: photonvision*.xz
# This is done after uploading the image to avoid contaminating the image with logs, caches, etc.
- uses: photonvision/photon-image-runner@HEAD
name: Smoketest Image
with:
image_url: file://${{ env.smoketest_image_loc }}
minimum_free_mb: ${{ matrix.minimum_free_mb }}
root_location: ${{ matrix.root_location || 'partition=2' }}
shrink_image: ${{ matrix.shrink_image || 'yes' }}
commands: java -jar *.jar --smoketest --platform=${{ matrix.plat_override }}
matrix-checker:
# This job always runs last to set the overall result based on the matrix jobs. If any matrix job failed, this job will fail.
# This makes it so that we don't need to add each matrix job individually to CI checks.
runs-on: ubuntu-latest
needs: [build-image]
if: always()
steps:
- run: ${{!contains(needs.*.result, 'failure')}}
release:
needs: [build-photonlib-vendorjson, build-package, build-image, combine]
runs-on: ubuntu-22.04
# Require smoketest-native so that if those fail, we don't release broken artifacts
needs: [build-photonlib-vendorjson, build-image, combine, build-package-linux, build-package-macos, build-package-windows, run-smoketest-native]
if: (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && github.repository == 'PhotonVision/photonvision'
runs-on: ubuntu-24.04
steps:
# Download all fat JARs
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v7
with:
merge-multiple: true
pattern: jar-*
# Download offline photonlib
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v7
with:
merge-multiple: true
pattern: photonlib-offline
# Download vendor json
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v7
with:
merge-multiple: true
pattern: photonlib-vendor-json
# Download all images
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v7
with:
merge-multiple: true
pattern: image-*
@@ -611,7 +650,7 @@ jobs:
**/photonlib*.zip
if: github.event_name == 'push'
- name: Create Vendor JSON Repo PR
uses: wpilibsuite/vendor-json-repo/.github/actions/add_vendordep@main
uses: wpilibsuite/vendor-json-repo/.github/actions/add_vendordep@HEAD
with:
repo: PhotonVision/vendor-json-repo
token: ${{ secrets.VENDOR_JSON_REPO_PUSH_TOKEN }}

View File

@@ -0,0 +1,22 @@
name: Dependency Submission
on:
push:
branches: [ 'main' ]
permissions:
contents: write
jobs:
dependency-submission:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v6
- name: Setup Java
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: 17
- name: Generate and submit dependency graph
uses: gradle/actions/dependency-submission@v5

View File

@@ -9,6 +9,6 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5
- uses: actions/labeler@v6
with:
sync-labels: true

View File

@@ -14,24 +14,24 @@ jobs:
name: "Validation"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: gradle/actions/wrapper-validation@v4
- uses: actions/checkout@v6
- uses: gradle/actions/wrapper-validation@v5
wpiformat:
name: "wpiformat"
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Fetch all history and metadata
run: |
git fetch --prune --unshallow
git checkout -b pr
git branch -f main origin/main
- name: Set up Python 3.8
uses: actions/setup-python@v4
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: 3.11
python-version: 3.14
- name: Install wpiformat
run: pip3 install wpiformat==2025.75
run: pip3 install wpiformat==2025.79
- name: Run
run: wpiformat
- name: Check output
@@ -46,7 +46,7 @@ jobs:
- name: Generate diff
run: git diff HEAD > wpiformat-fixes.patch
if: ${{ failure() }}
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
with:
name: wpiformat fixes
path: wpiformat-fixes.patch
@@ -54,12 +54,12 @@ jobs:
javaformat:
name: "Java Formatting"
needs: [validation]
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-java@v4
- uses: actions/setup-java@v5
with:
java-version: 17
distribution: temurin
@@ -77,15 +77,15 @@ jobs:
defaults:
run:
working-directory: photon-client
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm

View File

@@ -20,22 +20,22 @@ jobs:
name: "Validation"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: gradle/actions/wrapper-validation@v4
- uses: actions/checkout@v6
- uses: gradle/actions/wrapper-validation@v5
build_demo:
name: Build PhotonClient Demo
defaults:
run:
working-directory: photon-client
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
@@ -44,7 +44,7 @@ jobs:
run: pnpm i --frozen-lockfile
- name: Build Production Client
run: pnpm run build-demo
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
with:
name: built-demo
path: photon-client/dist/
@@ -52,16 +52,16 @@ jobs:
run_java_cpp_docs:
name: Build Java and C++ API Docs
needs: [validation]
runs-on: "ubuntu-22.04"
runs-on: "ubuntu-24.04"
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Fetch tags
run: git fetch --tags --force
- name: Install Java 17
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: 17
distribution: temurin
@@ -69,7 +69,7 @@ jobs:
run: |
chmod +x gradlew
./gradlew photon-docs:generateJavaDocs photon-docs:doxygen
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
with:
name: docs-java-cpp
path: photon-docs/build/docs
@@ -77,10 +77,10 @@ jobs:
publish_api_docs:
name: Publish API Docs
needs: [run_java_cpp_docs]
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
# Download docs artifact
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v7
with:
pattern: docs-*
- run: find .
@@ -104,9 +104,9 @@ jobs:
publish_demo:
name: Publish PhotonClient Demo
needs: [build_demo]
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v7
with:
name: built-demo
- run: find .

View File

@@ -14,14 +14,14 @@ env:
jobs:
build:
name: Build and Check Docs
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-python@v4
- uses: actions/setup-python@v6
with:
python-version: '3.11'
python-version: 3.14
- name: Install and upgrade pip
run: python -m pip install --upgrade pip

View File

@@ -12,57 +12,134 @@ concurrency:
cancel-in-progress: true
jobs:
buildAndDeploy:
runs-on: ubuntu-22.04
build-py:
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: 3.11
python-version: 3.14
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel pytest mypy
pip install setuptools wheel
- name: Build wheel
working-directory: ./photon-lib/py
run: |
python setup.py sdist bdist_wheel
- name: Run Unit Tests
working-directory: ./photon-lib/py
run: |
pip install --no-cache-dir dist/*.whl
pytest
# Disable due to robotpy issue. See
# https://github.com/PhotonVision/photonvision/issues/1968
# - name: Run mypy type checking
# uses: liskin/gh-problem-matcher-wrap@v3
# with:
# linters: mypy
# run: |
# mypy --show-column-numbers --config-file photon-lib/py/pyproject.toml photon-lib
run: python setup.py sdist bdist_wheel
- name: Upload artifacts
uses: actions/upload-artifact@master
uses: actions/upload-artifact@v6
with:
name: dist
path: ./photon-lib/py/dist/
- name: Publish package distributions to TestPyPI
# Only upload on tags
if: startsWith(github.ref, 'refs/tags/v')
test-py:
needs: build-py
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: 3.14
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest mypy
- name: Download artifacts
uses: actions/download-artifact@v6
with:
name: dist
path: dist/
- name: Install package
shell: bash
run: pip install --no-cache-dir dist/*.whl
- name: Run Unit Tests
shell: bash
run: pytest --import-mode=importlib photon-lib/py/test/
- name: Run mypy type checking
run: mypy --show-column-numbers --config-file photon-lib/py/pyproject.toml photon-lib
build-python-examples:
needs: build-py
strategy:
matrix:
os: [ubuntu-24.04, windows-2022, macos-14]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: 3.14
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- name: Download artifacts
uses: actions/download-artifact@v6
with:
name: dist
path: ./photon-lib/py/dist/
- name: Install PhotonLibPy package
working-directory: ./photon-lib/py
shell: bash
run: |
pip install --no-cache-dir dist/*.whl
- name: Build Python examples
working-directory: photonlib-python-examples
shell: bash
run: |
for folder in */;
do
echo $folder
./run.sh $folder
done
deploy:
needs: [test-py, build-python-examples]
runs-on: ubuntu-24.04
# Only upload on tags
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Download artifacts
uses: actions/download-artifact@v6
with:
name: dist
path: dist/
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages_dir: ./photon-lib/py/dist/
packages-dir: ./dist/
permissions:
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing

View File

@@ -9,13 +9,13 @@ jobs:
name: Build and Sync Files
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
@@ -39,13 +39,13 @@ jobs:
name: Check Formatting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm

View File

@@ -1 +1 @@
3.11
3.14

View File

@@ -6,9 +6,9 @@ sphinx:
fail_on_warning: true
build:
os: ubuntu-22.04
os: ubuntu-24.04
tools:
python: "3.11"
python: "3.12"
apt_packages:
- graphviz
jobs:

View File

@@ -1,5 +1,6 @@
{
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"python.testing.cwd": "photon-lib/py"
"python.testing.cwd": "photon-lib/py",
"java.configuration.updateBuildConfiguration": "automatic"
}

View File

@@ -3,22 +3,6 @@ cppHeaderFileInclude {
}
modifiableFileExclude {
\.dll$
\.gif$
\.ico$
\.jpeg$
\.jpg$
\.mp4$
\.pdf$
\.png$
\.rknn$
\.so$
\.svg$
\.tflite$
\.ttf$
\.webp$
\.woff2$
gradlew
photon-lib/py/photonlibpy/generated/
photon-targeting/src/generated/
photon-targeting/src/main/native/cpp/photon/constrained_solvepnp/generate/

View File

@@ -8,18 +8,18 @@ The latest release of platform-specific jars and images is found [here](https://
If you are interested in contributing code or documentation to the project, please [read our getting started page for contributors](https://docs.photonvision.org/en/latest/docs/contributing/index.html) and **[join the Discord](https://discord.gg/wYxTwym) to introduce yourself!** We hope to provide a welcoming community to anyone who is interested in helping.
## Authors
<a href="https://github.com/PhotonVision/photonvision/graphs/contributors">
<img src="https://contrib.rocks/image?repo=PhotonVision/photonvision" />
</a>
## Documentation
- Our main documentation page: [docs.photonvision.org](https://docs.photonvision.org)
- Photon UI demo: [demo.photonvision.org](https://demo.photonvision.org)
- Javadocs: [javadocs.photonvision.org](https://javadocs.photonvision.org)
- C++ Doxygen [cppdocs.photonvision.org](https://cppdocs.photonvision.org)
- C++ Doxygen: [cppdocs.photonvision.org](https://cppdocs.photonvision.org)
## Authors
<a href="https://github.com/PhotonVision/photonvision/graphs/contributors">
<img src="https://contrib.rocks/image?repo=PhotonVision/photonvision" />
</a>
## Building
@@ -32,7 +32,6 @@ You can run one of the many built in examples straight from the command line, to
Note that these are case sensitive!
* `-PArchOverride=foobar`: builds for a target system other than your current architecture. [Valid overrides](https://github.com/wpilibsuite/wpilib-tool-plugin/blob/main/src/main/java/edu/wpi/first/tools/NativePlatforms.java) are:
* winx32
* winx64
* winarm64
* macx64
@@ -46,31 +45,34 @@ Note that these are case sensitive!
- `-Pprofile`: enables JVM profiling
- `-PwithSanitizers`: On Linux, enables `-fsanitize=address,undefined,leak`
If you're cross-compiling, you'll need the wpilib toolchain installed. This can be done via Gradle: for example `./gradlew installArm64Toolchain` or `./gradlew installRoboRioToolchain`
If you're cross-compiling, you'll need the WPILib toolchain installed. This must be done via Gradle: for example `./gradlew installArm64Toolchain` or `./gradlew installRoboRioToolchain`
## Out-of-Source Dependencies
PhotonVision uses the following additional out-of-source repositories for building code.
- Base system images for Raspberry Pi & Orange Pi: https://github.com/PhotonVision/photon-image-modifier
- Base system images for supported coprocessors: https://github.com/PhotonVision/photon-image-modifier
- C++ driver for Raspberry Pi CSI cameras: https://github.com/PhotonVision/photon-libcamera-gl-driver
- JNI code for [mrcal](https://mrcal.secretsauce.net/): https://github.com/PhotonVision/mrcal-java
- Custom build of OpenCV with GStreamer/Protobuf/other custom flags: https://github.com/PhotonVision/thirdparty-opencv
- JNI code for aruco-nano: https://github.com/PhotonVision/aruconano-jni
- JNI code for RKNN: https://github.com/PhotonVision/rknn_jni
- JNI code for Rubik Pi NPU: https://github.com/PhotonVision/rubik_jni
## Acknowledgments
PhotonVision was forked from [Chameleon Vision](https://github.com/Chameleon-Vision/chameleon-vision/). Thank you to everyone who worked on the original project.
* [WPILib](https://github.com/wpilibsuite) - Specifically [cscore](https://github.com/wpilibsuite/allwpilib/tree/main/cscore), [CameraServer](https://github.com/wpilibsuite/allwpilib/tree/main/cameraserver), [NTCore](https://github.com/wpilibsuite/allwpilib/tree/main/ntcore), and [OpenCV](https://github.com/wpilibsuite/thirdparty-opencv).
* [Apache Commons](https://commons.apache.org/) - Specifically [Commons Math](https://commons.apache.org/proper/commons-math/), and [Commons Lang](https://commons.apache.org/proper/commons-lang/)
* [WPILib](https://github.com/wpilibsuite) - Specifically [allwpilib](https://github.com/wpilibsuite/allwpilib) and [their build of OpenCV](https://github.com/wpilibsuite/thirdparty-opencv).
* [Apache Commons](https://commons.apache.org/) - Specifically [Commons IO](https://commons.apache.org/proper/commons-io/), and [Commons CLI](https://commons.apache.org/proper/commons-cli/)
* [diozero](https://www.diozero.com/)
* [EJML](https://github.com/lessthanoptimal/ejml)
* [Javalin](https://javalin.io/)
* [JSON](https://json.org)
* [FasterXML](https://github.com/FasterXML) - Specifically [jackson](https://github.com/FasterXML/jackson)
* [MessagePack for Java](https://github.com/msgpack/msgpack-java)
* [OSHI](https://github.com/oshi/oshi)
* [QuickBuffers](https://github.com/HebiRobotics/QuickBuffers)
* [SQLite JDBC](https://github.com/xerial/sqlite-jdbc)
* [ZT ZIP](https://github.com/zeroturnaround/zt-zip)
## License

View File

@@ -2,10 +2,10 @@ import edu.wpi.first.toolchain.*
plugins {
id "cpp"
id "com.diffplug.spotless" version "6.24.0"
id "com.diffplug.spotless" version "8.1.0"
id "edu.wpi.first.wpilib.repositories.WPILibRepositoriesPlugin" version "2020.2"
id "edu.wpi.first.GradleRIO" version "2026.1.1-beta-1"
id 'edu.wpi.first.WpilibTools' version '1.3.0'
id "edu.wpi.first.GradleRIO" version "2026.2.1"
id 'org.photonvision.tools.WpilibTools' version '2.3.3-photon'
id 'com.google.protobuf' version '0.9.3' apply false
id 'edu.wpi.first.GradleJni' version '1.1.0'
id "org.ysb33r.doxygen" version "2.0.0" apply false
@@ -15,11 +15,11 @@ plugins {
allprojects {
repositories {
maven { url = "https://frcmaven.wpi.edu/artifactory/ex-mvn/" }
mavenCentral()
mavenLocal()
maven { url = "https://maven.photonvision.org/releases" }
maven { url = "https://maven.photonvision.org/snapshots" }
maven { url = "https://jogamp.org/deployment/maven/" }
}
wpilibRepositories.addAllReleaseRepositories(it)
wpilibRepositories.addAllDevelopmentRepositories(it)
@@ -32,29 +32,22 @@ ext.allOutputsFolder = file("$project.buildDir/outputs")
apply from: "versioningHelper.gradle"
ext {
wpilibVersion = "2026.1.1-beta-1"
wpilibVersion = "2026.2.1"
wpimathVersion = wpilibVersion
openCVYear = "2025"
openCVversion = "4.10.0-3"
javalinVersion = "6.7.0"
libcameraDriverVersion = "dev-v2025.0.4-2-gc91d4b7"
rknnVersion = "dev-v2025.0.0-7-g83c1bf3"
rubikVersion = "dev-v2025.1.0-7-g39588a8"
frcYear = "2026beta"
mrcalVersion = "dev-v2025.0.0-2-g2adb187";
libcameraDriverVersion = "v2026.0.0"
rknnVersion = "v2026.0.1"
rubikVersion = "v2026.0.1"
frcYear = "2026"
mrcalVersion = "v2026.0.0";
pubVersion = versionString
isDev = pubVersion.startsWith("dev")
// A list, for legacy reasons, with only the current platform contained
wpilibNativeName = wpilibTools.platformMapper.currentPlatform.platformName;
def nativeName = wpilibNativeName
if (wpilibNativeName == "linuxx64") nativeName = "linuxx86-64";
if (wpilibNativeName == "winx64") nativeName = "windowsx86-64";
if (wpilibNativeName == "macx64") nativeName = "osxx86-64";
if (wpilibNativeName == "macarm64") nativeName = "osxarm64";
jniPlatform = nativeName
jniPlatform = wpilibTools.platformMapper.wpilibClassifier;
println("Building for platform " + jniPlatform + " wpilib: " + wpilibNativeName)
println("Using Wpilib: " + wpilibVersion)
@@ -119,5 +112,6 @@ subprojects {
options.addStringOption("charset", "utf-8")
options.addStringOption("docencoding", "utf-8")
options.addStringOption("encoding", "utf-8")
options.addBooleanOption("Xdoclint/package:-org.photonvision.proto,-org.photonvision.struct,-org.photonvision.targeting.proto,-org.photonvision.jni", true)
}
}

View File

@@ -21,7 +21,6 @@ mdurl==0.1.2
myst-parser==4.0.1
packaging==25.0
pbr==6.1.1
pipreqs==0.5.0
Pygments==2.19.1
PyYAML==6.0.2
requests==2.32.4
@@ -54,6 +53,6 @@ stevedore==5.4.1
typing_extensions==4.13.2
urllib3==2.5.0
uvicorn==0.34.2
watchfiles==1.0.5
watchfiles==1.1.1
websockets==15.0.1
yarg==0.1.9

View File

@@ -3,13 +3,15 @@
## About
:::{warning}
PhotonVision interfaces with PhotonLib, our vendor dependency, using NetworkTables. If you are running PhotonVision on a robot (ie. with a RoboRIO), you should **turn the NetworkTables server switch (in the settings tab) off** in order to get PhotonLib to work. Also ensure that you set your team number. The NetworkTables server should only be enabled if you know what you're doing!
PhotonVision interfaces with PhotonLib, our vendor dependency, using NetworkTables. If you are running PhotonVision on a robot (ie. with a RoboRIO), you should **turn the NetworkTables server switch (in the settings tab) off** in order to get PhotonLib to work. Also ensure that you set your team number. **The NetworkTables server should only be enabled if you know what you're doing!**
:::
## API
:::{warning}
NetworkTables is not a supported setup/viable option when using PhotonVision as we only send one target at a time (this is problematic when using AprilTags, which will return data from multiple tags at once). We recommend using PhotonLib.
NetworkTables is not a supported setup/viable option when using PhotonVision as we only send one target at a time (this is problematic when using AprilTags, which will return data from multiple tags at once).
**We strongly recommend using PhotonLib instead, as the NetworkTables API will most likely be removed in 2027.**
:::
The tables below contain the the name of the key for each entry that PhotonVision sends over the network and a short description of the key. The entries should be extracted from a subtable with your camera's nickname (visible in the PhotonVision UI) under the main `photonvision` table.

View File

@@ -3,6 +3,6 @@
"supportURL" : "https://limelightvision.io",
"ledPins" : [ 13, 18 ],
"ledsCanDim" : true,
"ledPWMFrequency" : 30000,
"ledPWMFrequency" : 1000,
"vendorFOV" : 75.76079874010732
}

View File

@@ -23,7 +23,7 @@ AprilTag pipelines come with reasonable defaults to get you up and running with
Target families are defined by two numbers (before and after the h). The first number is the number of bits the tag is able to encode (which means more tags are available in the respective family) and the second is the hamming distance. Hamming distance describes the ability for error correction while identifying tag ids. A high hamming distance generally means that it will be easier for a tag to be identified even if there are errors. However, as hamming distance increases, the number of available tags decreases.
The 2025 FRC game will be using 36h11 tags, which can be found [here](https://github.com/AprilRobotics/apriltag-imgs/tree/main/tag36h11).
The 2026 FRC game will be using 36h11 tags, which can be found [here](https://github.com/AprilRobotics/apriltag-imgs/tree/2bc821edb4eb7b408d13c6a590d326d8a9ec98f3/tag36h11).
### Decimate

View File

@@ -10,5 +10,5 @@ AprilTags are a common type of visual fiducial marker. Visual fiducial markers a
A more technical explanation can be found in the [WPILib documentation](https://docs.wpilib.org/en/latest/docs/software/vision-processing/apriltag/apriltag-intro.html).
:::{note}
You can get FIRST's [official PDF of the targets used in 2025 here](https://firstfrc.blob.core.windows.net/frc2025/FieldAssets/Apriltag_Images_and_User_Guide.pdf).
You can get FIRST's [official PDF of the targets used in 2026 here](https://firstfrc.blob.core.windows.net/frc2026/FieldAssets/2026-apriltag-images-user-guide.pdf).
:::

View File

@@ -7,7 +7,7 @@ MultiTag requires an accurate field layout JSON to be uploaded! Differences betw
:::
:::{warning}
For the 2025 Reefscape Season, there are two different field layouts. The first is the [welded field layout](https://github.com/wpilibsuite/allwpilib/blob/main/apriltag/src/main/native/resources/edu/wpi/first/apriltag/2025-reefscape-welded.json), which photonvision ships with. The second is the [Andymark field layout](https://github.com/wpilibsuite/allwpilib/blob/main/apriltag/src/main/native/resources/edu/wpi/first/apriltag/2025-reefscape-andymark.json). It is very important to ensure that you use the correct field layout, both in the [PhotonPoseEstimator](https://docs.photonvision.org/en/latest/docs/programming/photonlib/robot-pose-estimator.html#apriltags-and-photonposeestimator) and on the [coprocessor](https://docs.photonvision.org/en/latest/docs/apriltag-pipelines/multitag.html#updating-the-field-layout).
For the 2026 Rebuilt Season, there are two different field layouts. The first is the [welded field layout](https://github.com/wpilibsuite/allwpilib/blob/main/apriltag/src/main/native/resources/edu/wpi/first/apriltag/2026-rebuilt-welded.json), which photonvision ships with. The second is the [Andymark field layout](https://github.com/wpilibsuite/allwpilib/blob/main/apriltag/src/main/native/resources/edu/wpi/first/apriltag/2026-rebuilt-andymark.json). It is very important to ensure that you use the correct field layout, both in the [PhotonPoseEstimator](https://docs.photonvision.org/en/latest/docs/programming/photonlib/robot-pose-estimator.html#apriltags-and-photonposeestimator) and on the [coprocessor](https://docs.photonvision.org/en/latest/docs/apriltag-pipelines/multitag.html#updating-the-field-layout).
:::
## Enabling MultiTag
@@ -66,7 +66,7 @@ The returned field to camera transform is a transform from the fixed field origi
## Updating the Field Layout
PhotonVision ships by default with the [2025 welded field layout JSON](https://github.com/wpilibsuite/allwpilib/blob/main/apriltag/src/main/native/resources/edu/wpi/first/apriltag/2025-reefscape-welded.json). The layout can be inspected by navigating to the settings tab and scrolling down to the "AprilTag Field Layout" card, as shown below.
PhotonVision ships by default with the [2026 welded field layout JSON](https://github.com/wpilibsuite/allwpilib/blob/main/apriltag/src/main/native/resources/edu/wpi/first/apriltag/2026-rebuilt-welded.json). The layout can be inspected by navigating to the settings tab and scrolling down to the "AprilTag Field Layout" card, as shown below.
```{image} images/field-layout.png
:alt: The currently saved field layout in the Photon UI

View File

@@ -83,7 +83,7 @@ Details about a particular calibration can be viewed by clicking on that resolut
More info on what these parameters mean can be found in [OpenCV's docs](https://docs.opencv.org/4.8.0/d4/d94/tutorial_camera_calibration.html)
:::
- Fx/Fy: Estimated camera focal length, in mm
- Fx/Fy: Estimated camera focal length, in pixels
- Fx/Cy: Estimated camera optical center, in pixels. This should be at about the center of the image
- Distortion: OpenCV camera model distortion coefficients
- FOV: calculated using estimated focal length and image size. Useful for gut-checking calibration results

View File

@@ -7,7 +7,7 @@ The following example is from the PhotonLib example repository ([Java](https://g
- A Robot
- 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.
- [A printout of AprilTag 7](https://firstfrc.blob.core.windows.net/frc2026/FieldAssets/2026-apriltag-images-user-guide.pdf), mounted on a rigid and flat surface.
## Code

View File

@@ -18,7 +18,7 @@ The {code}`Drivetrain` class includes functionality to fuse multiple sensor read
Please reference the [WPILib documentation](https://docs.wpilib.org/en/stable/docs/software/advanced-controls/state-space/state-space-pose_state-estimators.html) on using the {code}`SwerveDrivePoseEstimator` class.
We use the 2024 game's AprilTag Locations:
We use the current game's AprilTag Locations:
```{eval-rst}
.. tab-set-code::

View File

@@ -73,27 +73,21 @@ If you were using custom LED commands from 2025 or earlier and still need custom
## Hardware Interaction Commands
For Non-Raspberry-Pi hardware, users must provide valid hardware-specific commands for some parts of the UI interaction (including performance metrics, and executing system restarts).
For non-Linux hardware, users must provide the hardware-specific command for executing system restarts.
Leaving a command blank will disable the associated functionality.
Leaving this command blank will disable the restart functionality.
```{eval-rst}
.. tab-set-code::
.. code-block:: json
{
"cpuTempCommand" : "",
"cpuMemoryCommand" : "",
"cpuUtilCommand" : "",
"gpuMemoryCommand" : "",
"gpuTempCommand" : "",
"ramUtilCommand" : "",
"restartHardwareCommand" : "",
}
```
:::{note}
These settings have no effect if PhotonVision detects it is running on a Raspberry Pi. See [the MetricsBase class](https://github.com/PhotonVision/photonvision/blob/dbd631da61b7c86b70fa6574c2565ad57d80a91a/photon-core/src/main/java/org/photonvision/common/hardware/metrics/MetricsBase.java) for the commands utilized.
This setting has no effect if PhotonVision detects it is running on Linux. On Linux, the restart is accomplished by executing `reboot now` in a shell.
:::
## Known Camera FOV
@@ -150,13 +144,7 @@ Here is a complete example `hardwareConfig.json`:
"setGPIOCommand" : "setGPIO {p} {s}",
"setPWMCommand" : "setPWM {p} {v}",
"setPWMFrequencyCommand" : "setPWMFrequency {p} {f}",
"releaseGPIOCommand" : "releseGPIO {p}",
"cpuTempCommand" : "",
"cpuMemoryCommand" : "",
"cpuUtilCommand" : "",
"gpuMemoryCommand" : "",
"gpuTempCommand" : "",
"ramUtilCommand" : "",
"releaseGPIOCommand" : "releaseGPIO {p}",
"restartHardwareCommand" : "",
"vendorFOV" : 72.5
}

View File

@@ -88,7 +88,7 @@ Cameras capable of capturing a good image with very short exposures will also he
### Using Multiple Cameras
Keeping the target(s) in view of the robot often requires more than one camera. PhotonVision has no hardcoded limit on the number of cameras supported. The limit is usually dependant on CPU (can all frames be processed fast enough?) and USB bandwidth (Can all cameras send their images without overwhelming the bus?).
Keeping the target(s) in view of the robot often requires more than one camera. PhotonVision has no hardcoded limit on the number of cameras supported. The limit is usually dependent on CPU (can all frames be processed fast enough?) and USB bandwidth (Can all cameras send their images without overwhelming the bus?).
Note that cameras are not synchronized together. Frames are captured and processed asynchronously. Robot Code must fuse estimates together. For more information, see {ref}`the programming reference. <docs/programming/index:programming reference>`.

View File

@@ -4,7 +4,7 @@
PhotonVision supports object detection using neural network accelerator hardware, commonly known as an NPU. The two coprocessors currently supported are the {ref}`Orange Pi 5 <docs/objectDetection/opi:Orange Pi 5 (and variants) Object Detection>` and the {ref}`Rubik Pi 3 <docs/objectDetection/rubik:Rubik Pi 3 Object Detection>`.
PhotonVision currently ships with a model trained on the [COCO dataset](https://cocodataset.org/) by [Ultralytics](https://github.com/ultralytics/ultralytics) (this model is licensed under [AGPLv3](https://www.gnu.org/licenses/agpl-3.0.en.html)). This model is meant to be used for testing and other miscellaneous purposes. It is not meant to be used in competition. For the 2025 post-season, PhotonVision also ships with a pretrained ALGAE model. A model to detect coral is available in the PhotonVision discord, but will not be distributed with PhotonVision.
PhotonVision currently ships with a model trained on the [COCO dataset](https://cocodataset.org/) by [Ultralytics](https://github.com/ultralytics/ultralytics) (this model is licensed under [AGPLv3](https://www.gnu.org/licenses/agpl-3.0.en.html)). This model is meant to be used for testing and other miscellaneous purposes. It is not meant to be used in competition. For the 2026 season, PhotonVision ships with a model to detect FUEL, this is also licensed under AGPL.
## Tracking Objects

View File

@@ -0,0 +1,37 @@
# FPS Limiter
:::{warning}
When using the FPS limiter, it's important to disable it before a match begins.
:::
The FPS limiter can be used to lower the frames processed per second for a given camera. This is intended to be used for power-saving, particularly in the case of high FPS cameras with powerful coprocessors. The value passed to the function will indicate the frames per second that should be processed. A value of -1 should be passed to indicate that the FPS limiter should not restrict processing; this is the default behavior.
```{eval-rst}
.. tab-set-code::
.. code-block:: java
int limit = camera.getFPSLimit();
camera.setFPSLimit(10);
// This removes any previously set FPS limit.
camera.setFPSLimit(-1);
.. code-block:: c++
int limit = camera.GetFPSLimit();
camera.SetFPSLimit(10);
// This removes any previously set FPS limit.
camera.SetFPSLimit(-1);
.. code-block:: python
limit = camera.getFPSLimit()
camera.setFPSLimit(10)
# This removes any previously set FPS limit.
camera.setFPSLimit(-1)
```

View File

@@ -9,4 +9,5 @@ using-target-data
robot-pose-estimator
driver-mode-pipeline-index
controlling-led
fps-limiter
```

View File

@@ -48,91 +48,88 @@ Another necessary argument for creating a `PhotonPoseEstimator` is the `Transfor
## Creating a `PhotonPoseEstimator`
The PhotonPoseEstimator has a constructor that takes an `AprilTagFieldLayout` (see above), `PoseStrategy`, `PhotonCamera`, and `Transform3d`. `PoseStrategy` has nine possible values:
- MULTI_TAG_PNP_ON_COPROCESSOR
- Calculates a new robot position estimate by combining all visible tag corners. Recommended for all teams as it will be the most accurate.
- Must configure the AprilTagFieldLayout properly in the UI, please see {ref}`here <docs/apriltag-pipelines/multitag:multitag localization>` for more information.
- LOWEST_AMBIGUITY
- Choose the Pose with the lowest ambiguity.
- CLOSEST_TO_CAMERA_HEIGHT
- Choose the Pose which is closest to the camera height.
- CLOSEST_TO_REFERENCE_POSE
- Choose the Pose which is closest to the pose from setReferencePose().
- CLOSEST_TO_LAST_POSE
- Choose the Pose which is closest to the last pose calculated.
- AVERAGE_BEST_TARGETS
- Choose the Pose which is the average of all the poses from each tag.
- MULTI_TAG_PNP_ON_RIO
- A slower, older version of MULTI_TAG_PNP_ON_COPROCESSOR, not recommended for use.
- PNP_DISTANCE_TRIG_SOLVE
- 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. Based on a reference implementation by [FRC Team 6328 Mechanical Advantage](https://www.chiefdelphi.com/t/frc-6328-mechanical-advantage-2025-build-thread/477314/98).
- CONSTRAINED_SOLVEPNP
- Solve a constrained version of the Perspective-n-Point problem with the robot's drivebase
flat on the floor. This computation takes place on the RoboRIO, and should not take more than 2ms.
This also requires addHeadingData to be called every frame so heading data is up to date.
If Multi-Tag PNP is enabled on the coprocessor, it will be used to provide an initial seed to
the optimization algorithm -- otherwise, the multi-tag fallback strategy will be used as the
seed.
The PhotonPoseEstimator has a constructor that takes an `AprilTagFieldLayout` (see above) and `Transform3d`.
```{eval-rst}
.. tab-set-code::
.. rli:: https://raw.githubusercontent.com/PhotonVision/photonvision/refs/heads/main/photonlib-java-examples/poseest/src/main/java/frc/robot/Vision.java
:language: java
:lines: 65-66
:lines: 63
.. rli:: https://raw.githubusercontent.com/PhotonVision/photonvision/refs/heads/main/photonlib-cpp-examples/poseest/src/main/include/Vision.h
:language: c++
:lines: 150-153
:lines: 149-150
.. rli:: https://raw.githubusercontent.com/PhotonVision/photonvision/refs/heads/main/photonlib-python-examples/poseest/robot.py
:language: python
:lines: 45-50
:lines: 45-48
```
:::{note}
Python still takes a `PhotonCamera` in the constructor, so you must create the camera as shown in the next section and then return and use it to create the `PhotonPoseEstimator`.
:::
## Using a `PhotonPoseEstimator`
The final prerequisite to using your `PhotonPoseEstimator` is creating a `PhotonCamera`. To do this, you must set the name of your camera in Photon Client. From there you can define the camera in code.
To use your `PhotonPoseEstimator`, you must create a `PhotonCamera` and feed the results into your `PhotonPoseEstimator`. To do this, you must first set the name of your camera in Photon Client. From there you can define the camera in code.
```{eval-rst}
.. tab-set-code::
.. rli:: https://raw.githubusercontent.com/PhotonVision/photonvision/refs/heads/main/photonlib-java-examples/poseest/src/main/java/frc/robot/Vision.java
:language: java
:lines: 63
:lines: 62
.. rli:: https://raw.githubusercontent.com/PhotonVision/photonvision/refs/heads/main/photonlib-cpp-examples/aimattarget/src/main/include/Robot.h
.. rli:: https://raw.githubusercontent.com/PhotonVision/photonvision/refs/heads/main/photonlib-cpp-examples/poseest/src/main/include/Vision.h
:language: c++
:lines: 55
:lines: 151
.. rli:: https://raw.githubusercontent.com/PhotonVision/photonvision/refs/heads/main/photonlib-python-examples/poseest/robot.py
:language: python
:lines: 44
```
Calling `update()` on your `PhotonPoseEstimator` will return an `EstimatedRobotPose`, which includes a `Pose3d` of the latest estimated pose (using the selected strategy) along with a `double` of the timestamp when the robot pose was estimated.
When taking in a result from a `PhotonCamera`, PhotonPoseEstimator offers nine possible "strategies" for calculating a pose from a pipeline result in the form of methods that you can call, following the pattern `estimate<strategy name>Pose`:
- Coprocessor MultiTag (`estimateCoprocMultiTagPose`)
- Calculates a new robot position estimate by combining all visible tag corners. Recommended for all teams as it will be the most accurate.
- Must configure the AprilTagFieldLayout properly in the UI, please see {ref}`here <docs/apriltag-pipelines/multitag:multitag localization>` for more information.
- Lowest Ambiguity (`estimateLowestAmbiguityPose`)
- Choose the Pose with the lowest ambiguity.
- Closest to Camera Height (`estimateClosestToCameraHeightPose`)
- Choose the Pose which is closest to the camera height.
- Closest to Reference Pose (`estimateClosestToReferencePose`)
- Choose the Pose which is closest to the pose that is passed into the function.
- Average Best Targets (`estimateAverageBestTargetsPose`)
- Choose the Pose which is the average of all the poses from each tag.
- roboRio MultiTag (`estimateRioMultiTagPose`)
- A slower, older version of Coprocessor MultiTag, not recommended for use.
- PnP Distance Trig Solve (`estimatePnpDistanceTrigSolvePose`)
- 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. Based on a reference implementation by [FRC Team 6328 Mechanical Advantage](https://www.chiefdelphi.com/t/frc-6328-mechanical-advantage-2025-build-thread/477314/98).
- Constrained SolvePnP (`estimateConstrainedSolvepnpPose`)
- Solve a constrained version of the Perspective-n-Point problem with the robot's drivebase
flat on the floor. This computation takes place on the RoboRIO, and should not take more than 2ms.
This also requires addHeadingData to be called every frame so heading data is up to date.
Calling one of the `estimate<strategy>Pose()` methods on your `PhotonPoseEstimator` will return an `Optional<EstimatedRobotPose>`, which will be empty if there are no detected tags, not enough detected tags (for multi-tag strategies), missing data (typically heading data), or if the internal solvers failed (this is a rare scenario). `EstimatedRobotPose` includes a `Pose3d` of the latest estimated pose (using the selected strategy) along with a `double` of the timestamp when the robot pose was estimated. The recommended way to use the estimatePose methods is to
1. do estimation with one of MultiTag methods, check if the result is empty, then
2. fallback to single tag estimation using a method like `estimateLowestAmbiguityPose`.
```{eval-rst}
.. tab-set-code::
.. rli:: https://raw.githubusercontent.com/PhotonVision/photonvision/refs/heads/main/photonlib-java-examples/poseest/src/main/java/frc/robot/Vision.java
:language: java
:lines: 93-116
:lines: 91-94
.. rli:: https://raw.githubusercontent.com/PhotonVision/photonvision/refs/heads/main/photonlib-cpp-examples/poseest/src/main/include/Vision.h
:language: c++
:lines: 80-100
:lines: 79-82
.. rli:: https://raw.githubusercontent.com/PhotonVision/photonvision/refs/heads/main/photonlib-python-examples/poseest/robot.py
:language: python
:lines: 53
:lines: 52-54
```
You should be updating your [drivetrain pose estimator](https://docs.wpilib.org/en/latest/docs/software/advanced-controls/state-space/state-space-pose-estimators.html) with the result from the `PhotonPoseEstimator` every loop using `addVisionMeasurement()`.
For Constrained SolvePnP, it's recommended to do the previously mentioned steps, and then feed the pose (if it exists) into `estimateConstrainedSolvepnpPose`, and if the Constrained SolvePnP result is empty, simply feed the seed pose into your drivetrain pose estimator.
Once you have the `Optional<EstimatedRobotPose>`, you can check to see if there's an actual pose inside, and act accordingly. You should be updating your [drivetrain pose estimator](https://docs.wpilib.org/en/latest/docs/software/advanced-controls/state-space/state-space-pose-estimators.html) with the result from the `PhotonPoseEstimator` every loop using `addVisionMeasurement()`. For Java and C++, the examples pass a method from the drivetrain to a `Vision` object, with the parameter being called `estConsumer`. Python calls the drivetrain directly.
```{eval-rst}
.. tab-set-code::
@@ -146,7 +143,22 @@ You should be updating your [drivetrain pose estimator](https://docs.wpilib.org/
.. rli:: https://raw.githubusercontent.com/PhotonVision/photonvision/refs/heads/main/photonlib-python-examples/poseest/robot.py
:language: python
:lines: 54-57
:lines: 56-58
```
```{eval-rst}
.. tab-set-code::
.. rli:: https://raw.githubusercontent.com/PhotonVision/photonvision/refs/heads/main/photonlib-java-examples/poseest/src/main/java/frc/robot/Vision.java
:language: java
:lines: 89-115
.. rli:: https://raw.githubusercontent.com/PhotonVision/photonvision/refs/heads/main/photonlib-cpp-examples/poseest/src/main/include/Vision.h
:language: c++
:lines: 77-100
.. rli:: https://raw.githubusercontent.com/PhotonVision/photonvision/refs/heads/main/photonlib-python-examples/poseest/robot.py
:language: python
:lines: 51-54
```
## Complete Examples

View File

@@ -29,7 +29,11 @@ Unless otherwise noted in release notes or if updating from the prior years vers
Use the [Raspberry Pi Imager](https://www.raspberrypi.com/software/) to flash the image onto the coprocessors microSD card. Select the downloaded `.img.xz` file, select your microSD card, and flash.
:::{warning}
Balena Etcher can also be used, but historically has had issues such as bootlooping (the system will repeatedly boot and restart) when imaging your device. Use at your own risk.
Avoid using Raspberry Pi Imager version 2.0.2 or later. Those versions fail to write the image to an SD card. Versions 2.0.0 and earlier write images successfully. [GitHub issue 1489](https://github.com/raspberrypi/rpi-imager/issues/1489) was created for this problem.
:::
:::{warning}
Balena Etcher has been recommended in the past, but should no longer be used due to instability and lack of ongoing support from developers.
:::
## Limelight Installation
@@ -54,3 +58,32 @@ The Qualcomm Launcher caches files. If you flash multiple times, you may need to
:::
To flash the Rubik Pi 3 coprocessor, it's necessary to use the [Qualcomm Launcher](https://softwarecenter.qualcomm.com/catalog/item/Qualcomm_Launcher). Upload a custom image by selecting the *Custom* option in the launcher. Choose the downloaded PhotonVision `.tar.xz` file and follow the prompts to complete the installation. It is recommended to skip the *Configure Login* process, as PhotonVision will handle the necessary settings.
### Alternative Flashing Method (advanced users only)
Follow the specific steps listed below from the [Rubik Pi 3 Docs](https://www.thundercomm.com/rubik-pi-3/en/docs/rubik-pi-3-user-manual/1.0.0-u/Troubleshooting/11.1.flash-over-android/).
[Step 1](https://www.thundercomm.com/rubik-pi-3/en/docs/rubik-pi-3-user-manual/1.0.0-u/Troubleshooting/11.1.flash-over-android/#1%EF%B8%8F%E2%83%A3-setup-qdl-tool) should be completed once per computer. [Step 2](https://www.thundercomm.com/rubik-pi-3/en/docs/rubik-pi-3-user-manual/1.0.0-u/Troubleshooting/11.1.flash-over-android/#2%EF%B8%8F%E2%83%A3-ufs-provisioning) and [Step 3](https://www.thundercomm.com/rubik-pi-3/en/docs/rubik-pi-3-user-manual/1.0.0-u/Troubleshooting/11.1.flash-over-android/#3%EF%B8%8F%E2%83%A3-flash-renesas-firmware) should be completed once per Rubik Pi 3.
After completing these steps, unzip your downloaded PhotonVision image to a folder. Navigate to that folder in your terminal or command prompt. After putting your Rubik Pi 3 into EDL mode, run the command below to flash PhotonVision. There is no need to complete any further steps from the Rubik Pi 3 documentation after running this command.
::::{tab-set}
:::{tab-item} Ubuntu host
```shell
qdl --storage ufs prog_firehose_ddr.elf rawprogram*.xml patch*.xml
```
:::
:::{tab-item} Windows host
```shell
QDL.exe prog_firehose_ddr.elf rawprogram0.xml rawprogram1.xml rawprogram2.xml rawprogram3.xml rawprogram4.xml rawprogram5.xml rawprogram6.xml patch1.xml patch2.xml patch3.xml patch4.xml patch5.xml patch6.xml
```
:::
:::{tab-item} macOS host
```shell
qdl prog_firehose_ddr.elf rawprogram*.xml patch*.xml
```
:::
::::

View File

@@ -2,15 +2,21 @@
## Coprocessor with regulator
1. **IT IS STRONGLY RECOMMENDED** to use one of the recommended power regulators to prevent vision from cutting out from voltage drops while operating the robot. We recommend wiring the regulator directly to the power header pins or using a locking USB C cable. In any case we recommend hot gluing the connector.
1. **IT IS STRONGLY RECOMMENDED** to use one of the recommended power regulators to prevent vision from cutting out from voltage drops while operating the robot. We recommend wiring the regulator directly to the power header pins using either of the two methods listed below or using a locking USB C cable.
* Method 1: Soldering to GPIO Header Pins
* Using 20 AWG or preferably 18 AWG wires, solder two wires from the regulator to the power header pins on the coprocessor and cover with heat-shrink tubing.
* Method 2: Using a Wire-to-Board Connector
* Using a wire-to-board connector with 20 AWG or preferably 18 AWG wires, connect two wires from the regulator to the power header pins on the coprocessor. To prevent the connector from becoming unseated, we recommend applying hot glue to the connector.
2. Run an ethernet cable from your Pi to your network switch / radio.
2. Run an ethernet cable from your coprocessor to your network switch / radio.
This diagram shows how to use the recommended regulator to power a coprocessor.
## Raspberry Pi and Orange Pi
This diagram shows how to use the recommended regulator to power a Raspberry Pi or Orange Pi.
::::{tab-set}
:::{tab-item} Orange Pi Zinc V USB C
:::{tab-item} Orange Pi 5 Zinc V USB C
```{image} images/OrangePiZincUSBC.png
:alt: Wiring the opi5 to the pdp using the Redux Robotics Zinc V and usb c
@@ -78,6 +84,12 @@ This diagram shows how to use the recommended regulator to power a coprocessor.
Pigtails can be purchased from many sources we recommend [(USB C)](https://ctr-electronics.com/products/usb-type-c-wire-breakout?_pos=19&_sid=bf06b6a6b&_ss=r) [(Micro USB)](https://ctr-electronics.com/products/usb-micro-power-wire-breakout?pr_prod_strat=e5_desc&pr_rec_id=10bf36ce7&pr_rec_pid=7863771070637&pr_ref_pid=7863771103405&pr_seq=uniform)
## RUBIK Pi
The RUBIK Pi has very different power requirements than the Orange Pi (or standard Raspberry Pi). In particular it requires 12V inputs, and has
a higher maximum power draw than those coprocessors. [First Rubik](https://first-rubik.github.io/docs/power/) has recommendations for both
on-robot and off-robot scenarios.
## Limelight
Follow the wiring instructions located in the [Limelight Documentation](https://docs.limelightvision.io/) for your Limelight model.

View File

@@ -22,6 +22,7 @@
"@mdi/font": "^7.4.47",
"@msgpack/msgpack": "^3.1.2",
"axios": "^1.11.0",
"echarts": "^6.0.0",
"jspdf": "^3.0.1",
"pinia": "^3.0.2",
"three": "^0.178.0",

View File

@@ -20,6 +20,9 @@ importers:
axios:
specifier: ^1.11.0
version: 1.11.0
echarts:
specifier: ^6.0.0
version: 6.0.0
jspdf:
specifier: ^3.0.1
version: 3.0.1
@@ -848,6 +851,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
echarts@6.0.0:
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
@@ -1408,6 +1414,9 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -1548,6 +1557,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zrender@6.0.0:
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
snapshots:
'@babel/helper-string-parser@7.27.1': {}
@@ -2062,7 +2074,7 @@ snapshots:
typescript: 5.8.3
vue: 3.5.13(typescript@5.8.3)
'@vuetify/loader-shared@2.1.0(vue@3.5.13(typescript@5.8.3))(vuetify@3.8.3)':
'@vuetify/loader-shared@2.1.0(vue@3.5.13(typescript@5.8.3))(vuetify@3.8.3(typescript@5.8.3)(vite-plugin-vuetify@2.1.1)(vue@3.5.13(typescript@5.8.3)))':
dependencies:
upath: 2.0.1
vue: 3.5.13(typescript@5.8.3)
@@ -2215,6 +2227,11 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
echarts@6.0.0:
dependencies:
tslib: 2.3.0
zrender: 6.0.0
entities@4.5.0: {}
es-define-property@1.0.1: {}
@@ -2784,6 +2801,8 @@ snapshots:
dependencies:
typescript: 5.8.3
tslib@2.3.0: {}
tslib@2.8.1: {}
type-check@0.4.0:
@@ -2819,7 +2838,7 @@ snapshots:
vite-plugin-vuetify@2.1.1(vite@7.0.5(@types/node@22.15.14)(sass@1.89.2))(vue@3.5.13(typescript@5.8.3))(vuetify@3.8.3):
dependencies:
'@vuetify/loader-shared': 2.1.0(vue@3.5.13(typescript@5.8.3))(vuetify@3.8.3)
'@vuetify/loader-shared': 2.1.0(vue@3.5.13(typescript@5.8.3))(vuetify@3.8.3(typescript@5.8.3)(vite-plugin-vuetify@2.1.1)(vue@3.5.13(typescript@5.8.3)))
debug: 4.4.0
upath: 2.0.1
vite: 7.0.5(@types/node@22.15.14)(sass@1.89.2)
@@ -2889,3 +2908,7 @@ snapshots:
xml-name-validator@4.0.0: {}
yocto-queue@0.1.0: {}
zrender@6.0.0:
dependencies:
tslib: 2.3.0

View File

@@ -8,8 +8,10 @@ import { onBeforeUnmount, onMounted, watchEffect } from "vue";
const {
ArrowHelper,
BoxGeometry,
CameraHelper,
Color,
ConeGeometry,
Group,
Mesh,
MeshNormalMaterial,
PerspectiveCamera,
@@ -20,6 +22,18 @@ const {
} = await import("three");
const { TrackballControls } = await import("three/examples/jsm/controls/TrackballControls");
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { createPerspectiveCamera } from "@/lib/ThreeUtils";
import { useTheme } from "vuetify";
const theme = useTheme();
const calibrationCoeffs = useCameraSettingsStore().getCalibrationCoeffs(
useCameraSettingsStore().currentCameraSettings.validVideoFormats[
useCameraSettingsStore().currentPipelineSettings.cameraVideoModeIndex
].resolution
);
const props = defineProps<{
targets: PhotonTarget[];
}>();
@@ -30,17 +44,20 @@ let renderer: WebGLRenderer | undefined;
let controls: TrackballControls | undefined;
let previousTargets: Object3D[] = [];
const drawTargets = (targets: PhotonTarget[]) => {
const drawTargets = async (targets: PhotonTarget[]) => {
// Check here, since if we check in watchEffect this never gets called
if (scene === undefined || camera === undefined || renderer === undefined || controls === undefined) {
if (!scene || !camera || !renderer || !controls) {
return;
}
if (theme.global.name.value === "LightTheme") scene.background = new Color(0xa9a9a9);
else scene.background = new Color(0x000000);
scene.remove(...previousTargets);
previousTargets = [];
targets.forEach((target) => {
if (target.pose === undefined) return;
if (!target.pose) return;
const geometry = new BoxGeometry(0.3 / 5, 0.2, 0.2);
const material = new MeshNormalMaterial();
@@ -70,6 +87,22 @@ const drawTargets = (targets: PhotonTarget[]) => {
previousTargets.push(arrow);
});
if (calibrationCoeffs) {
// And show camera frustum
const calibCamera = await createPerspectiveCamera(
calibrationCoeffs.resolution,
calibrationCoeffs.cameraIntrinsics,
10
);
const helper = new CameraHelper(calibCamera);
const helperGroup = new Group();
helperGroup.add(helper);
// Flip to +Z forward
helperGroup.rotateX(-Math.PI / 2.0);
helperGroup.rotateY(-Math.PI / 2.0);
previousTargets.push(helperGroup);
}
if (previousTargets.length > 0) {
scene.add(...previousTargets);
}
@@ -78,7 +111,7 @@ const onWindowResize = () => {
const container = document.getElementById("container");
const canvas = document.getElementById("view");
if (container === null || canvas === null || camera === undefined || renderer === undefined) {
if (!container || !canvas || !camera || !renderer) {
return;
}
@@ -89,7 +122,7 @@ const onWindowResize = () => {
renderer.setSize(canvas.clientWidth, canvas.clientHeight);
};
const resetCamFirstPerson = () => {
if (scene === undefined || camera === undefined || controls === undefined) {
if (!scene || !camera || !controls) {
return;
}
@@ -103,7 +136,7 @@ const resetCamFirstPerson = () => {
}
};
const resetCamThirdPerson = () => {
if (scene === undefined || camera === undefined || controls === undefined) {
if (!scene || !camera || !controls) {
return;
}
@@ -122,10 +155,11 @@ onMounted(async () => {
camera = new PerspectiveCamera(75, 800 / 800, 0.1, 1000);
const canvas = document.getElementById("view");
if (canvas === null) return;
if (!canvas) return;
renderer = new WebGLRenderer({ canvas: canvas });
scene.background = new Color(0xa9a9a9);
if (theme.global.name.value === "LightTheme") scene.background = new Color(0xa9a9a9);
else scene.background = new Color(0x000000);
onWindowResize();
window.addEventListener("resize", onWindowResize);
@@ -169,7 +203,7 @@ onMounted(async () => {
controls.update();
const animate = () => {
if (scene === undefined || camera === undefined || renderer === undefined || controls === undefined) {
if (!scene || !camera || !renderer || !controls) {
return;
}
@@ -192,18 +226,31 @@ watchEffect(() => {
<template>
<div id="container" style="width: 100%">
<v-row>
<v-col align-self="stretch" style="display: flex; justify-content: center">
<canvas id="view" />
<div class="d-flex flex-wrap pt-0 pb-2">
<v-col cols="12" md="6" class="pl-0">
<v-card-title class="pa-0"> Target Visualization </v-card-title>
</v-col>
</v-row>
<v-row style="margin-bottom: 24px">
<v-col style="display: flex; justify-content: center">
<v-btn color="secondary" @click="resetCamFirstPerson"> First Person </v-btn>
<v-col cols="6" md="3" class="d-flex align-center pt-0 pt-md-3 pl-6 pl-md-3">
<v-btn
style="width: 100%"
color="buttonActive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="resetCamFirstPerson"
>
First Person
</v-btn>
</v-col>
<v-col style="display: flex; justify-content: center">
<v-btn color="secondary" @click="resetCamThirdPerson"> Third Person </v-btn>
<v-col cols="6" md="3" class="d-flex align-center pt-0 pt-md-3 pr-0">
<v-btn
style="width: 100%"
color="buttonActive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="resetCamThirdPerson"
>
Third Person
</v-btn>
</v-col>
</v-row>
</div>
<canvas id="view" class="w-100" />
</div>
</template>

View File

@@ -0,0 +1,371 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch, watchEffect, type Ref } from "vue";
const {
AmbientLight,
AxesHelper,
BoxGeometry,
CameraHelper,
Color,
ConeGeometry,
Group,
Mesh,
MeshNormalMaterial,
MeshPhongMaterial,
PerspectiveCamera,
Scene,
SphereGeometry,
WebGLRenderer
} = await import("three");
const { TrackballControls } = await import("three/examples/jsm/controls/TrackballControls");
import type { BoardObservation, CameraCalibrationResult } from "@/types/SettingTypes";
import axios from "axios";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { useTheme } from "vuetify";
import { createPerspectiveCamera } from "@/lib/ThreeUtils";
const theme = useTheme();
const props = defineProps<{
cameraUniqueName: string;
resolution: { width: number; height: number };
title: string;
}>();
let scene: Scene | undefined;
let camera: PerspectiveCamera | undefined;
let renderer: WebGLRenderer | undefined;
let controls: TrackballControls | undefined;
const createChessboard = (obs: BoardObservation, cal: CameraCalibrationResult): Group => {
const group = new Group();
if (obs.locationInImageSpace.length === 0) return group;
// Add corner spheres
obs.locationInObjectSpace.forEach((corner, idx) => {
if (corner.x < 0 || corner.y < 0) return;
const isOutlier = !obs.cornersUsed[idx];
const color = isOutlier ? 0xff3333 : 0x33ff33;
const sphereGeom = new SphereGeometry(cal.calobjectSpacing / 8, 8, 8);
const sphereMat = new MeshPhongMaterial({
color: color,
opacity: 1,
transparent: !isOutlier
});
const sphere = new Mesh(sphereGeom, sphereMat);
sphere.position.set(corner.x, corner.y, corner.z);
group.add(sphere);
});
return group;
};
let previousTargets: Object3D[] = [];
let baseAspect: number | undefined;
const drawCalibration = async (cal: CameraCalibrationResult | null) => {
// Check here, since if we check in watchEffect this never gets called
if (!cal || !scene || !camera || !renderer || !controls) {
return;
}
scene.remove(...previousTargets);
previousTargets = [];
// Draw all chessboards with transparency
cal.observations.forEach((obs) => {
const pose = obs.optimisedCameraToObject;
// Create chessboard
const board = createChessboard(obs, cal);
board.userData.isCalibrationObject = true;
// Apply transform from camera to chessboard
const pos = pose.translation;
board.position.set(pos.x, pos.y, pos.z);
if (pose.rotation.quaternion) {
const q = pose.rotation.quaternion;
board.quaternion.set(q.X, q.Y, q.Z, q.W);
}
previousTargets.push(board);
});
// And show camera frustum
const calibCamera = await createPerspectiveCamera(props.resolution, cal.cameraIntrinsics);
const helper = new CameraHelper(calibCamera);
// Flip to +Z forward
const helperGroup = new Group();
helperGroup.add(helper);
helperGroup.rotateY(Math.PI);
previousTargets.push(helperGroup);
if (previousTargets.length > 0) {
scene.add(...previousTargets);
}
};
const calibrationData: Ref<CameraCalibrationResult | null> = ref(null);
const isLoading: Ref<boolean> = ref(true);
const error: Ref<string | null> = ref(null);
const fetchCalibrationData = async () => {
isLoading.value = true;
error.value = null;
try {
const response = await axios.get("/settings/camera/getCalibration", {
params: {
cameraUniqueName: props.cameraUniqueName,
width: props.resolution.width,
height: props.resolution.height
}
});
calibrationData.value = response.data;
} catch (err) {
console.error("Failed to fetch calibration data:", err);
error.value = "Failed to load calibration data";
} finally {
isLoading.value = false;
}
};
const onWindowResize = () => {
const container = document.getElementById("container");
const canvas = document.getElementById("view");
if (!container || !canvas || !camera || !renderer) {
return;
}
// Compute a concrete width from the container and derive height from a
// stable base aspect ratio (calculated on mount) to avoid feedback loops
// where updating canvas size changes container size while resizing
const width = Math.max(1, Math.floor(container.clientWidth));
let height: number;
if (baseAspect && baseAspect > 0) {
height = Math.max(1, Math.floor(width / baseAspect));
} else {
height = Math.max(1, Math.floor(container.clientHeight));
}
// Use updateStyle=false so Three.js does not write to canvas style,
// which can affect layout and re-trigger resize events
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
};
const resetCamFirstPerson = () => {
if (!scene || !camera || !controls) {
return;
}
controls.reset();
camera.position.set(0, 0, 0.05);
camera.up.set(0, -1, 0);
controls.target.set(0.0, 0.0, 1.0);
controls.update();
if (previousTargets.length > 0) {
scene.add(...previousTargets);
}
};
const resetCamThirdPerson = () => {
if (!scene || !camera || !controls) {
return;
}
controls.reset();
camera.position.set(-0.3, -0.2, -0.3);
camera.up.set(0, -1, 0);
controls.target.set(0.0, 0.0, 0.4);
controls.update();
if (previousTargets.length > 0) {
scene.add(...previousTargets);
}
};
let animationFrameId: number | null = null;
onMounted(async () => {
// Grab data first off
fetchCalibrationData();
scene = new Scene();
camera = new PerspectiveCamera(75, 800 / 800, 0.1, 1000);
const canvas = document.getElementById("view");
if (!canvas) return;
renderer = new WebGLRenderer({ canvas: canvas });
// Add lights
const ambientLight = new AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
if (theme.global.name.value === "LightTheme") scene.background = new Color(0xa9a9a9);
else scene.background = new Color(0x000000);
// Initialize a stable aspect ratio so subsequent resize events derive
// height from width, avoiding layout feedback during continuous resizing
try {
const initWidth = Math.max(1, Math.floor(document.getElementById("container")?.clientWidth || 1));
const initHeight = Math.max(1, Math.floor(document.getElementById("container")?.clientHeight || 1));
baseAspect = initWidth / Math.max(1, initHeight);
} catch {
baseAspect = undefined;
}
onWindowResize();
window.addEventListener("resize", onWindowResize);
const referenceFrameCues: Object3D[] = [];
// Draw the reference frame
referenceFrameCues.push(new AxesHelper(0.3));
// Draw the Camera Body
const camSize = 0.04;
const camBodyGeometry = new BoxGeometry(camSize, camSize, camSize);
const camLensGeometry = new ConeGeometry(camSize * 0.4, camSize * 0.8, 30);
const camMaterial = new MeshNormalMaterial();
const camBody = new Mesh(camBodyGeometry, camMaterial);
const camLens = new Mesh(camLensGeometry, camMaterial);
camBody.position.set(0, 0, 0);
camLens.rotateX(-Math.PI / 2);
camLens.position.set(0, 0, camSize * 0.8);
referenceFrameCues.push(camBody);
referenceFrameCues.push(camLens);
controls = new TrackballControls(camera, renderer.domElement);
controls.rotateSpeed = 1.0;
controls.zoomSpeed = 1.2;
controls.panSpeed = 0.8;
controls.noZoom = false;
controls.noPan = false;
controls.staticMoving = true;
controls.dynamicDampingFactor = 0.3;
scene.add(...referenceFrameCues);
resetCamThirdPerson();
controls.update();
const animate = () => {
if (!scene || !camera || !renderer || !controls) {
return;
}
animationFrameId = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
});
const cleanup = () => {
window.removeEventListener("resize", onWindowResize);
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
if (controls) {
controls.dispose();
}
if (renderer) {
renderer.dispose();
renderer.forceContextLoss();
}
if (scene) {
scene.traverse((object) => {
if (object instanceof Mesh) {
object.geometry?.dispose();
if (object.material) {
if (Array.isArray(object.material)) {
object.material.forEach((material) => material.dispose());
} else {
object.material.dispose();
}
}
}
});
}
scene = undefined;
camera = undefined;
renderer = undefined;
controls = undefined;
previousTargets = [];
};
onBeforeUnmount(cleanup);
// If hot-reloading, cleanup on hot reload
if (import.meta.hot) {
import.meta.hot.dispose(() => {
cleanup();
});
}
watchEffect(() => {
drawCalibration(calibrationData.value);
});
watch(
() => [
props.cameraUniqueName,
props.resolution.width,
props.resolution.height,
useCameraSettingsStore().getCalibrationCoeffs(props.resolution)
],
() => {
console.log("Camera or resolution changed, refetching calibration");
fetchCalibrationData();
}
);
</script>
<template>
<div style="width: 100%; height: 100%" class="d-flex flex-column">
<div class="d-flex flex-wrap pt-0 pb-2">
<v-col cols="12" md="6" class="pl-0">
<v-card-title class="pa-0">
{{ props.title }}
</v-card-title>
</v-col>
<v-col cols="6" md="3" class="d-flex align-center pt-0 pt-md-3 pl-6 pl-md-3">
<v-btn
style="width: 100%"
color="buttonActive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="resetCamFirstPerson"
>
First Person
</v-btn>
</v-col>
<v-col cols="6" md="3" class="d-flex align-center pt-0 pt-md-3 pr-0">
<v-btn
style="width: 100%"
color="buttonActive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="resetCamThirdPerson"
>
Third Person
</v-btn>
</v-col>
</div>
<div id="container" style="flex: 1 1 auto">
<canvas id="view" class="w-100 h-100" />
</div>
</div>
</template>

View File

@@ -14,6 +14,7 @@ import { getResolutionString, resolutionsAreEqual } from "@/lib/PhotonUtils";
import CameraCalibrationInfoCard from "@/components/cameras/CameraCalibrationInfoCard.vue";
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
import { useTheme } from "vuetify";
import TooltippedLabel from "@/components/common/pv-tooltipped-label.vue";
const PromptRegular = import("@/assets/fonts/PromptRegular");
const jspdf = import("jspdf");
@@ -38,7 +39,8 @@ const getUniqueVideoFormatsByResolution = (): VideoFormat[] => {
if (!skip) {
const calib = useCameraSettingsStore().getCalibrationCoeffs(format.resolution);
if (calib !== undefined) {
// For each error, square it, sum the squares, and divide by total points N
// Mean overall reprojection error
// Calculated as average of each observation's mean error
if (calib.meanErrors.length)
format.mean = calib.meanErrors.reduce((a, b) => a + b, 0) / calib.meanErrors.length;
else format.mean = NaN;
@@ -98,6 +100,7 @@ const patternHeight = ref(8);
const boardType = ref<CalibrationBoardTypes>(CalibrationBoardTypes.Charuco);
const useOldPattern = ref(false);
const tagFamily = ref<CalibrationTagFamilies>(CalibrationTagFamilies.Dict_4X4_1000);
const requestedVideoFormatIndex = ref(0);
// Emperical testing - with stack size limit of 1MB, we can handle at -least- 700k points
const tooManyPoints = computed(
@@ -190,6 +193,7 @@ const startCalibration = () => {
useCameraSettingsStore().currentCameraSettings.currentPipelineIndex = WebsocketPipelineType.Calib3d;
// isCalibrating.value = true;
calibCanceled.value = false;
requestedVideoFormatIndex.value = useStateStore().calibrationData.videoFormatIndex;
};
const showCalibEndDialog = ref(false);
const calibCanceled = ref(false);
@@ -240,7 +244,14 @@ const setSelectedVideoFormat = (format: VideoFormat) => {
<v-card class="mb-3 rounded-12" color="surface" dark>
<v-card-title>Camera Calibration</v-card-title>
<v-card-text v-if="!isCalibrating" class="pb-0">
<v-card-subtitle class="pa-0 pb-3 text-white">Current Calibrations</v-card-subtitle>
<div class="pb-3">
<tooltipped-label
label="Curent Calibrations"
icon="mdi-information"
location="top"
tooltip="Click on a resolution to view detailed calibration information and import/export a calibration."
/>
</div>
<v-table fixed-header height="100%" density="compact">
<thead>
<tr>
@@ -249,48 +260,40 @@ const setSelectedVideoFormat = (format: VideoFormat) => {
<th>Horizontal FOV</th>
<th>Vertical FOV</th>
<th>Diagonal FOV</th>
<th>Info</th>
</tr>
</thead>
<tbody style="cursor: pointer">
<tr v-for="(value, index) in getUniqueVideoFormatsByResolution()" :key="index">
<td>{{ getResolutionString(value.resolution) }}</td>
<td>
{{ value.mean !== undefined ? (isNaN(value.mean) ? "Unknown" : value.mean.toFixed(2) + "px") : "-" }}
</td>
<td>{{ value.horizontalFOV !== undefined ? value.horizontalFOV.toFixed(2) + "°" : "-" }}</td>
<td>{{ value.verticalFOV !== undefined ? value.verticalFOV.toFixed(2) + "°" : "-" }}</td>
<td>{{ value.diagonalFOV !== undefined ? value.diagonalFOV.toFixed(2) + "°" : "-" }}</td>
<v-tooltip location="bottom">
<template #activator="{ props }">
<td v-bind="props" @click="setSelectedVideoFormat(value)">
<v-icon size="small" color="primary">mdi-information</v-icon>
<v-tooltip
v-for="(value, index) in getUniqueVideoFormatsByResolution()"
:key="index"
transition=""
location="bottom"
:open-delay="100"
>
<template #activator="{ props }">
<tr :key="index" v-bind="props" @click="setSelectedVideoFormat(value)">
<td>{{ getResolutionString(value.resolution) }}</td>
<td>
{{
value.mean !== undefined ? (isNaN(value.mean) ? "Unknown" : value.mean.toFixed(2) + "px") : "-"
}}
</td>
</template>
<span>View calibration information</span>
</v-tooltip>
</tr>
<td>{{ value.horizontalFOV !== undefined ? value.horizontalFOV.toFixed(2) + "°" : "-" }}</td>
<td>{{ value.verticalFOV !== undefined ? value.verticalFOV.toFixed(2) + "°" : "-" }}</td>
<td>{{ value.diagonalFOV !== undefined ? value.diagonalFOV.toFixed(2) + "°" : "-" }}</td>
</tr>
</template>
<span>View calibration information</span>
</v-tooltip>
</tbody>
</v-table>
</v-card-text>
<v-card-text class="pt-0">
<div v-if="useCameraSettingsStore().isConnected" class="d-flex flex-column">
<v-card-subtitle v-if="!isCalibrating" class="pl-0 pb-3 pt-3 text-white"
<v-card-subtitle v-if="!isCalibrating" class="pl-0 pb-3 pt-4 opacity-100"
>Configure New Calibration</v-card-subtitle
>
<v-form ref="form" v-model="settingsValid">
<v-alert
closable
density="compact"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'tonal'"
:color="useSettingsStore().general.mrCalWorking ? 'buttonPassive' : 'error'"
:icon="useSettingsStore().general.mrCalWorking ? 'mdi-check' : 'mdi-close'"
:text="
useSettingsStore().general.mrCalWorking
? 'Mrcal was successfully loaded and will be used!'
: 'MrCal failed to load, check journalctl logs for details.'
"
/>
<pv-select
v-model="uniqueVideoResolutionString"
label="Resolution"
@@ -463,7 +466,20 @@ const setSelectedVideoFormat = (format: VideoFormat) => {
"
/>
</div>
<div v-if="isCalibrating" class="d-flex justify-center align-center pt-10px pb-5">
<v-alert
closable
density="compact"
class="mb-5"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'tonal'"
:color="useSettingsStore().general.mrCalWorking ? 'buttonPassive' : 'error'"
:icon="useSettingsStore().general.mrCalWorking ? 'mdi-check' : 'mdi-close'"
:text="
useSettingsStore().general.mrCalWorking
? 'Mrcal was successfully loaded and will be used!'
: 'MrCal failed to load, check journalctl logs for details.'
"
/>
<div v-if="isCalibrating" class="d-flex justify-center align-center pb-5">
<v-chip
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'tonal'"
label
@@ -554,7 +570,7 @@ const setSelectedVideoFormat = (format: VideoFormat) => {
{{
useCameraSettingsStore().currentCameraSettings.validVideoFormats.map((f) =>
getResolutionString(f.resolution)
)[useStateStore().calibrationData.videoFormatIndex]
)[requestedVideoFormatIndex]
}}!
</v-card-text>
</template>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import PhotonCalibrationVisualizer from "@/components/app/photon-calibration-visualizer.vue";
import type { CameraCalibrationResult, VideoFormat } from "@/types/SettingTypes";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { useStateStore } from "@/stores/StateStore";
@@ -8,7 +9,6 @@ import { useTheme } from "vuetify";
import PvDeleteModal from "@/components/common/pv-delete-modal.vue";
const theme = useTheme();
const props = defineProps<{
videoFormat: VideoFormat;
}>();
@@ -79,8 +79,10 @@ const importCalibration = async () => {
};
interface ObservationDetails {
mean: number;
index: number;
mean: number;
numOutliers: number;
numMissing: number;
}
const currentCalibrationCoeffs = computed<CameraCalibrationResult | undefined>(() =>
@@ -92,7 +94,9 @@ const getObservationDetails = (): ObservationDetails[] | undefined => {
return coefficients?.meanErrors.map((m, i) => ({
index: i,
mean: parseFloat(m.toFixed(2))
mean: parseFloat(m.toFixed(2)),
numOutliers: coefficients.numOutliers[i],
numMissing: coefficients.numMissing[i]
}));
};
@@ -101,213 +105,236 @@ const exportCalibrationURL = computed<string>(() =>
);
const calibrationImageURL = (index: number) =>
useCameraSettingsStore().getCalImageUrl(inject<string>("backendHost") as string, props.videoFormat.resolution, index);
const tab = ref("details");
const viewingImg = ref(0);
</script>
<template>
<v-card color="surface" dark>
<div class="d-flex flex-wrap pt-2 pl-2 pr-2 align-center">
<v-col cols="12" md="6">
<v-card-title class="pa-0"> Calibration Details </v-card-title>
</v-col>
<v-col cols="12" md="6" class="d-flex align-center pt-0 pt-md-3">
<v-btn
color="buttonPassive"
class="mr-2"
style="flex: 1"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="openUploadPhotonCalibJsonPrompt"
>
<v-icon start size="large">mdi-import</v-icon>
<span>Import</span>
</v-btn>
<input
ref="importCalibrationFromPhotonJson"
type="file"
accept=".json"
style="display: none"
@change="importCalibration"
/>
<v-btn
color="buttonPassive"
class="mr-2"
:disabled="!currentCalibrationCoeffs"
style="flex: 1"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="openExportCalibrationPrompt"
>
<v-icon start size="large">mdi-export</v-icon>
<span>Export</span>
</v-btn>
<a
ref="exportCalibration"
style="color: black; text-decoration: none; display: none"
:href="exportCalibrationURL"
target="_blank"
/>
<v-btn
color="error"
:disabled="!currentCalibrationCoeffs"
style="flex: 1"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="() => (confirmRemoveDialog = { show: true, vf: props.videoFormat })"
>
<v-icon start size="large">mdi-delete</v-icon>
<span>Delete</span>
</v-btn>
</v-col>
</div>
<v-card-title class="pt-0 pb-0"
>{{ useCameraSettingsStore().currentCameraName }}@{{ getResolutionString(videoFormat.resolution) }}</v-card-title
>
<v-card-text v-if="!currentCalibrationCoeffs">
<v-alert
class="pt-3 pb-3"
color="primary"
density="compact"
text="The selected video format has not been calibrated."
icon="mdi-alert-circle-outline"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'tonal'"
/>
</v-card-text>
<v-card-text class="pt-0">
<v-table density="compact" style="width: 100%">
<template #default>
<thead>
<tr>
<th class="text-left">Name</th>
<th class="text-left">Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Fx</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.cameraIntrinsics.data[0].toFixed(2) || 0.0
}}
mm
</td>
</tr>
<tr>
<td>Fy</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.cameraIntrinsics.data[4].toFixed(2) || 0.0
}}
mm
</td>
</tr>
<tr>
<td>Cx</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.cameraIntrinsics.data[2].toFixed(2) || 0.0
}}
px
</td>
</tr>
<tr>
<td>Cy</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.cameraIntrinsics.data[5].toFixed(2) || 0.0
}}
px
</td>
</tr>
<tr>
<td>Distortion</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.distCoeffs.data.map((it) => parseFloat(it.toFixed(3))) || []
}}
</td>
</tr>
<tr>
<td>Mean Err</td>
<td>
{{
videoFormat.mean !== undefined
? isNaN(videoFormat.mean)
? "NaN"
: videoFormat.mean.toFixed(2) + "px"
: "-"
}}
</td>
</tr>
<tr>
<td>Horizontal FOV</td>
<td>
{{ videoFormat.horizontalFOV !== undefined ? videoFormat.horizontalFOV.toFixed(2) + "°" : "-" }}
</td>
</tr>
<tr>
<td>Vertical FOV</td>
<td>{{ videoFormat.verticalFOV !== undefined ? videoFormat.verticalFOV.toFixed(2) + "°" : "-" }}</td>
</tr>
<tr>
<td>Diagonal FOV</td>
<td>{{ videoFormat.diagonalFOV !== undefined ? videoFormat.diagonalFOV.toFixed(2) + "°" : "-" }}</td>
</tr>
<!-- Board warp, only shown for mrcal-calibrated cameras -->
<tr v-if="currentCalibrationCoeffs?.calobjectWarp?.length === 2">
<td>Board warp, X/Y</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.calobjectWarp?.map((it) => (it * 1000).toFixed(2) + " mm")
.join(" / ")
}}
</td>
</tr>
</tbody>
</template>
</v-table>
</v-card-text>
<v-card-title v-if="currentCalibrationCoeffs" class="pt-0 pb-0">Individual Observations</v-card-title>
<v-card-text v-if="currentCalibrationCoeffs" class="pt-0">
<v-data-table
density="compact"
style="width: 100%"
:headers="[
{ title: 'Observation Id', key: 'index' },
{ title: 'Mean Reprojection Error', key: 'mean' },
{ title: '', key: 'data-table-expand' }
]"
:items="getObservationDetails()"
item-value="index"
show-expand
>
<template #item.data-table-expand="{ internalItem, toggleExpand }">
<v-card-title class="pb-2">
<div class="d-flex flex-wrap">
<v-col cols="12" md="6" class="pa-0">
<v-card-title class="pa-0"> Calibration Details </v-card-title>
</v-col>
<v-col cols="6" md="3" class="d-flex align-center pt-0 pb-0 pl-0">
<v-btn
icon="mdi-eye"
class="text-none"
color="medium-emphasis"
size="small"
variant="text"
slim
@click="toggleExpand(internalItem)"
></v-btn>
</template>
color="buttonPassive"
style="width: 100%"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="openUploadPhotonCalibJsonPrompt"
>
<v-icon start size="large"> mdi-import</v-icon>
<span>Import</span>
</v-btn>
<input
ref="importCalibrationFromPhotonJson"
type="file"
accept=".json"
style="display: none"
@change="importCalibration"
/>
</v-col>
<v-col cols="6" md="3" class="d-flex align-center pt-0 pb-0 pr-0">
<v-btn
color="buttonPassive"
:disabled="!currentCalibrationCoeffs"
style="width: 100%"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="openExportCalibrationPrompt"
>
<v-icon start size="large">mdi-export</v-icon>
<span>Export</span>
</v-btn>
<a
ref="exportCalibration"
style="color: black; text-decoration: none; display: none"
:href="exportCalibrationURL"
target="_blank"
/>
</v-col>
</div>
</v-card-title>
<template #expanded-row="{ columns, item }">
<td :colspan="columns.length">
<div style="display: flex; justify-content: center; width: 100%">
<img :src="calibrationImageURL(item.index)" alt="observation image" class="snapshot-preview pt-2 pb-2" />
</div>
</td>
</template>
</v-data-table>
<v-card-text class="d-flex flex-row pt-0">
<v-col cols="4" class="pa-0">
<v-tabs v-model="tab" grow bg-color="surface" height="48" slider-color="buttonActive">
<v-tab key="details" value="details">Details</v-tab>
<v-tab key="observations" value="observations">Observations</v-tab>
</v-tabs>
<v-tabs-window v-model="tab" class="pt-3">
<v-tabs-window-item key="details" value="details">
<v-table style="width: 100%" density="compact">
<template #default>
<tbody>
<tr>
<td>Camera</td>
<td>
{{ useCameraSettingsStore().currentCameraName }}
</td>
</tr>
<tr>
<td>Resolution</td>
<td>
{{ getResolutionString(videoFormat.resolution) }}
</td>
</tr>
<tr>
<td>Fx</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.cameraIntrinsics.data[0].toFixed(2) || 0.0
}}
px
</td>
</tr>
<tr>
<td>Fy</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.cameraIntrinsics.data[4].toFixed(2) || 0.0
}}
px
</td>
</tr>
<tr>
<td>Cx</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.cameraIntrinsics.data[2].toFixed(2) || 0.0
}}
px
</td>
</tr>
<tr>
<td>Cy</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.cameraIntrinsics.data[5].toFixed(2) || 0.0
}}
px
</td>
</tr>
<tr>
<td>Distortion</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.distCoeffs.data.map((it) => parseFloat(it.toFixed(3))) || []
}}
</td>
</tr>
<tr>
<td>Mean Err</td>
<td>
{{
videoFormat.mean !== undefined
? isNaN(videoFormat.mean)
? "NaN"
: videoFormat.mean.toFixed(2) + "px"
: "-"
}}
</td>
</tr>
<tr>
<td>Horizontal FOV</td>
<td>
{{ videoFormat.horizontalFOV !== undefined ? videoFormat.horizontalFOV.toFixed(2) + "°" : "-" }}
</td>
</tr>
<tr>
<td>Vertical FOV</td>
<td>
{{ videoFormat.verticalFOV !== undefined ? videoFormat.verticalFOV.toFixed(2) + "°" : "-" }}
</td>
</tr>
<tr>
<td>Diagonal FOV</td>
<td>
{{ videoFormat.diagonalFOV !== undefined ? videoFormat.diagonalFOV.toFixed(2) + "°" : "-" }}
</td>
</tr>
<!-- Board warp, only shown for mrcal-calibrated cameras -->
<tr v-if="currentCalibrationCoeffs?.calobjectWarp?.length === 2">
<td>Board warp, X/Y</td>
<td>
{{
currentCalibrationCoeffs?.calobjectWarp?.map((it) => (it * 1000).toFixed(2) + " mm").join(" / ")
}}
</td>
</tr>
</tbody>
</template>
</v-table>
</v-tabs-window-item>
<v-tabs-window-item key="observations" value="observations">
<v-data-table
id="observations-table"
items-per-page-text="Page size:"
density="compact"
style="width: 100%"
:headers="[
{ title: 'Id', key: 'index' },
{ title: 'Mean Reprojection Error', key: 'mean' }
]"
:items="getObservationDetails()"
item-value="index"
show-expand
>
<template #item.data-table-expand="{ internalItem }">
<v-btn
class="text-none"
size="small"
variant="text"
slim
rounded
@click="viewingImg = internalItem.index"
>
<v-icon
size="large"
:color="viewingImg === internalItem.index ? 'buttonActive' : 'rgba(255, 255, 255, 0.7)'"
>mdi-eye</v-icon
>
</v-btn>
</template>
</v-data-table>
</v-tabs-window-item>
</v-tabs-window>
</v-col>
<v-col cols="8" class="pa-0 pl-6">
<v-card-text class="pa-0 fill-height d-flex justify-center align-center">
<div v-if="!currentCalibrationCoeffs">
<v-alert
class="pt-3 pb-3"
color="primary"
text="The selected video format has not been calibrated."
icon="mdi-alert-circle-outline"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'tonal'"
/>
</div>
<Suspense v-else-if="tab === 'details'">
<!-- Allows us to import three js when it's actually needed -->
<PhotonCalibrationVisualizer
:camera-unique-name="useCameraSettingsStore().currentCameraSettings.uniqueName"
:resolution="props.videoFormat.resolution"
title="Camera to Board Transforms"
/>
<template #fallback> Loading... </template>
</Suspense>
<div v-else style="display: flex; justify-content: center; width: 100%">
<img :src="calibrationImageURL(viewingImg)" alt="observation image" class="snapshot-preview pt-2 pb-2" />
</div>
</v-card-text>
</v-col>
</v-card-text>
</v-card>
@@ -322,11 +349,7 @@ const calibrationImageURL = (index: number) =>
<style scoped>
.snapshot-preview {
max-width: 55%;
}
@media only screen and (max-width: 512px) {
.snapshot-preview {
max-width: 100%;
}
max-width: 100%;
max-height: 100%;
}
</style>

View File

@@ -2,14 +2,19 @@
defineProps<{
label?: string;
tooltip?: string;
icon?: string;
location?: "top" | "bottom" | "left" | "right";
}>();
</script>
<template>
<div>
<v-tooltip :disabled="tooltip === undefined" location="right" open-delay="300">
<v-tooltip :disabled="tooltip === undefined" :location="location ?? 'right'" open-delay="300">
<template #activator="{ props }">
<span style="cursor: text !important" class="text-white" v-bind="props">{{ label }}</span>
<v-icon v-if="icon" small class="ml-2" color="primary" v-bind="props">
{{ icon }}
</v-icon>
</template>
<span>{{ tooltip }}</span>
</v-tooltip>

View File

@@ -158,6 +158,16 @@ const interactiveCols = computed(() =>
(args) => useCameraSettingsStore().changeCurrentPipelineSetting({ cameraWhiteBalanceTemp: args }, false)
"
/>
<pv-switch
v-model="useCameraSettingsStore().currentPipelineSettings.blockForFrames"
:disabled="!useCameraSettingsStore().currentCameraSettings.matchedCameraInfo.PVUsbCameraInfo"
label="Low Latency Mode"
:switch-cols="interactiveCols"
tooltip="When enabled, USB cameras wait for the next camera frame for lowest latency. When disabled, uses the most recent available frame for higher FPS."
@update:modelValue="
(args) => useCameraSettingsStore().changeCurrentPipelineSetting({ blockForFrames: args }, false)
"
/>
<pv-select
v-model="useCameraSettingsStore().currentPipelineSettings.inputImageRotationMode"
label="Orientation"

View File

@@ -9,20 +9,11 @@ const trackedTargets = computed<PhotonTarget[]>(() => useStateStore().currentPip
<template>
<div>
<v-row style="width: 100%">
<v-col>
<span class="text-white">Target Visualization</span>
</v-col>
</v-row>
<v-row style="width: 100%">
<v-col style="display: flex; align-items: center; justify-content: center">
<Suspense>
<!-- Allows us to import three js when it's actually needed -->
<photon3d-visualizer :targets="trackedTargets" />
<Suspense>
<!-- Allows us to import three js when it's actually needed -->
<photon3d-visualizer :targets="trackedTargets" />
<template #fallback> Loading... </template>
</Suspense>
</v-col>
</v-row>
<template #fallback> Loading... </template>
</Suspense>
</div>
</template>

View File

@@ -3,6 +3,7 @@ import PvSelect from "@/components/common/pv-select.vue";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { type ActivePipelineSettings, PipelineType, RobotOffsetPointMode } from "@/types/PipelineTypes";
import PvSwitch from "@/components/common/pv-switch.vue";
import PvSlider from "@/components/common/pv-slider.vue";
import { computed } from "vue";
import { RobotOffsetType } from "@/types/SettingTypes";
import { useStateStore } from "@/stores/StateStore";
@@ -58,14 +59,17 @@ const interactiveCols = computed(() =>
<template>
<div>
<pv-switch
v-model="useCameraSettingsStore().currentPipelineSettings.outputShowMultipleTargets"
label="Show Multiple Targets"
tooltip="If enabled, up to five targets will be displayed and sent via PhotonLib, instead of just one"
:disabled="isTagPipeline"
<pv-slider
v-model="useCameraSettingsStore().currentPipelineSettings.outputMaximumTargets"
label="Maximum Targets"
tooltip="The maximum number of targets to display and send."
:hidden="isTagPipeline"
:min="1"
:max="127"
:step="1"
:switch-cols="interactiveCols"
@update:modelValue="
(value) => useCameraSettingsStore().changeCurrentPipelineSetting({ outputShowMultipleTargets: value }, false)
(value) => useCameraSettingsStore().changeCurrentPipelineSetting({ outputMaximumTargets: value }, false)
"
/>
<pv-switch

View File

@@ -0,0 +1,510 @@
@ -0,0 +1,565 @@
<script setup lang="ts">
import { inject, computed, ref, watch } from "vue";
import { useStateStore } from "@/stores/StateStore";
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
import PvSelect from "@/components/common/pv-select.vue";
import PvDeleteModal from "@/components/common/pv-delete-modal.vue";
import MetricsChart from "./MetricsChart.vue";
import { useTheme } from "vuetify";
import { axiosPost, forceReloadPage } from "@/lib/PhotonUtils";
import TooltippedLabel from "@/components/common/pv-tooltipped-label.vue";
import { metricsHistorySnapshot } from "@/stores/settings/GeneralSettingsStore";
const theme = useTheme();
const restartProgram = async () => {
if (await axiosPost("/utils/restartProgram", "restart PhotonVision")) {
forceReloadPage();
}
};
const restartDevice = async () => {
if (await axiosPost("/utils/restartDevice", "restart the device")) {
forceReloadPage();
}
};
const address = inject<string>("backendHost");
const offlineUpdate = ref();
const openOfflineUpdatePrompt = () => {
offlineUpdate.value.click();
};
const handleOfflineUpdate = async () => {
const files = offlineUpdate.value.files;
if (files.length === 0) return;
const formData = new FormData();
formData.append("jarData", files[0]);
useStateStore().showSnackbarMessage({
message: "New Software Upload in Progress...",
color: "secondary",
timeout: -1
});
if (
await axiosPost("/utils/offlineUpdate", "upload new software", formData, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: ({ progress }) => {
const uploadPercentage = (progress || 0) * 100.0;
if (uploadPercentage < 99.5) {
useStateStore().showSnackbarMessage({
message: "New Software Upload in Progress",
color: "secondary",
timeout: -1,
progressBar: uploadPercentage,
progressBarColor: "primary"
});
}
}
})
) {
useStateStore().showSnackbarMessage({
message: "Installing uploaded software...",
color: "secondary",
timeout: -1
});
forceReloadPage();
}
};
const exportLogFile = ref();
const openExportLogsPrompt = () => {
exportLogFile.value.click();
};
const exportSettings = ref();
const openExportSettingsPrompt = () => {
exportSettings.value.click();
};
enum ImportType {
AllSettings,
HardwareConfig,
HardwareSettings,
NetworkConfig,
ApriltagFieldLayout
}
const showImportDialog = ref(false);
const importType = ref<ImportType | undefined>(undefined);
const importFile = ref<File | null>(null);
const handleSettingsImport = () => {
if (importType.value === undefined || importFile.value === null) return;
const formData = new FormData();
formData.append("data", importFile.value);
let settingsEndpoint: string;
switch (importType.value) {
case ImportType.HardwareConfig:
settingsEndpoint = "/hardwareConfig";
break;
case ImportType.HardwareSettings:
settingsEndpoint = "/hardwareSettings";
break;
case ImportType.NetworkConfig:
settingsEndpoint = "/networkConfig";
break;
case ImportType.ApriltagFieldLayout:
settingsEndpoint = "/aprilTagFieldLayout";
break;
default:
case ImportType.AllSettings:
settingsEndpoint = "";
break;
}
axiosPost(`/settings${settingsEndpoint}`, "import settings", formData, {
headers: { "Content-Type": "multipart/form-data" }
});
showImportDialog.value = false;
importType.value = undefined;
importFile.value = null;
};
const showFactoryReset = ref(false);
const nukePhotonConfigDirectory = async () => {
if (await axiosPost("/utils/nukeConfigDirectory", "delete the config directory")) {
forceReloadPage();
}
};
interface MetricItem {
header: string;
value?: string;
}
const generalMetrics = computed<MetricItem[]>(() => {
const stats = [
{ header: "Version", value: useSettingsStore().general.version || "Unknown" },
{ header: "Hardware Model", value: useSettingsStore().general.hardwareModel || "Unknown" },
{ header: "Platform", value: useSettingsStore().general.hardwarePlatform || "Unknown" },
{ header: "GPU Acceleration", value: useSettingsStore().general.gpuAcceleration || "None detected" }
];
if (!useSettingsStore().network.networkingDisabled) {
stats.push({ header: "IP Address", value: useSettingsStore().metrics.ipAddress || "Unknown" });
}
return stats;
});
// @ts-expect-error This uses Intl.DurationFormat which is newly implemented and not available in TS.
const durationFormatter = new Intl.DurationFormat("en", { style: "narrow" });
const platformMetrics = computed<MetricItem[]>(() => {
const metrics = useSettingsStore().metrics;
const stats = [
{
header: "Uptime",
value: (() => {
const seconds = metrics.uptime;
if (seconds === undefined) return "Unknown";
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return durationFormatter.format({
days: days,
hours: hours,
minutes: minutes,
seconds: secs
});
})()
}
];
if (metrics.npuUsage && metrics.npuUsage.length > 0) {
stats.push({
header: "NPU Usage",
value: metrics.npuUsage?.map((usage, index) => `Core${index} ${usage}%`).join(", ") || "Unknown"
});
}
if (metrics.gpuMem && metrics.gpuMemUtil && metrics.gpuMem > 0 && metrics.gpuMemUtil > 0) {
stats.push({
header: "GPU Memory Usage",
value: `${metrics.gpuMemUtil}MB of ${metrics.gpuMem}MB`
});
}
if (metrics.cpuThr) {
stats.push({
header: "CPU Throttling",
value: metrics.cpuThr.toString()
});
}
if (metrics.recvBitRate && metrics.recvBitRate !== -1) {
stats.push({
header: "Received Bit Rate",
value: `${(metrics.recvBitRate / 1e6).toFixed(5)} Mb/s`
});
}
return stats;
});
const cpuUsageData = ref<{ time: number; value: number }[]>([]);
const cpuMemoryUsageData = ref<{ time: number; value: number }[]>([]);
const cpuTempData = ref<{ time: number; value: number }[]>([]);
const networkUsageData = ref<{ time: number; value: number }[]>([]);
watch(metricsHistorySnapshot, () => {
cpuUsageData.value = metricsHistorySnapshot.value.map((entry) => ({
time: entry.time,
value: entry.metrics.cpuUtil ?? 0
}));
cpuMemoryUsageData.value = metricsHistorySnapshot.value.map((entry) => ({
time: entry.time,
value: entry.metrics.ramUtil === -1 ? -1 : ((entry.metrics.ramUtil ?? 0) / (entry.metrics.ramMem ?? -1.0)) * 100
}));
cpuTempData.value = metricsHistorySnapshot.value.map((entry) => ({
time: entry.time,
value: entry.metrics.cpuTemp ?? 0
}));
networkUsageData.value = metricsHistorySnapshot.value.map((entry) => ({
time: entry.time,
value: entry.metrics.sentBitRate === -1 ? -1 : (entry.metrics.sentBitRate ?? 0) / 1e6
}));
});
</script>
<template>
<v-row no-gutters>
<!-- Device control card -->
<v-col class="pr-3">
<v-card class="mb-3 rounded-12 fill-height d-flex flex-column justify-space-between" color="surface">
<v-card-title class="d-flex justify-space-between">
<span>Device Control</span>
</v-card-title>
<v-card-text class="flex-0-0">
<v-table>
<tbody>
<tr v-for="(item, itemIndex) in generalMetrics.concat(platformMetrics)" :key="itemIndex">
<td :key="itemIndex">
{{ item.header }}
</td>
<td :key="itemIndex">
{{ item.value }}
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
<v-card-text class="pt-0 flex-0-0">
<v-row>
<v-col>
<v-btn
color="buttonPassive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="useStateStore().showLogModal = true"
>
<v-icon start class="open-icon" size="large"> mdi-eye </v-icon>
<span class="open-label">View Logs</span>
</v-btn>
</v-col>
<v-col>
<v-btn
color="buttonPassive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="openExportLogsPrompt"
>
<v-icon start class="open-icon" size="large"> mdi-download </v-icon>
<span class="open-label">Download Logs</span>
<!-- Special hidden link that gets 'clicked' when the user exports journalctl logs -->
<a
ref="exportLogFile"
style="color: black; text-decoration: none; display: none"
:href="`http://${address}/api/utils/photonvision-journalctl.txt`"
download="photonvision-journalctl.txt"
target="_blank"
/>
</v-btn>
</v-col>
</v-row>
</v-card-text>
<v-card-text class="pt-0 flex-0-0">
<v-row>
<v-col>
<v-btn
color="buttonPassive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="() => (showImportDialog = true)"
>
<v-icon start class="open-icon" size="large"> mdi-import </v-icon>
<span class="open-label">Import Settings</span>
</v-btn>
</v-col>
<v-col>
<v-btn
color="buttonPassive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="openExportSettingsPrompt"
>
<v-icon start class="open-icon" size="large"> mdi-export </v-icon>
<span class="open-label">Export Settings</span>
</v-btn>
</v-col>
</v-row>
</v-card-text>
<v-card-text class="pt-0 flex-0-0">
<v-row>
<v-col cols="12" sm="6"
><v-btn
color="buttonActive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="restartProgram"
>
<v-icon start class="open-icon" size="large"> mdi-restart </v-icon>
<span class="open-label">Restart Software</span>
</v-btn>
</v-col>
<v-col cols="12" sm="6">
<v-btn
color="buttonPassive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="openOfflineUpdatePrompt"
>
<v-icon start class="open-icon" size="large"> mdi-upload </v-icon>
<span class="open-label">Offline Update</span>
</v-btn>
<input
ref="offlineUpdate"
type="file"
accept=".jar"
style="display: none"
@change="handleOfflineUpdate"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-text class="pt-0 flex-0-0">
<v-row>
<v-col cols="12" sm="6">
<v-btn
color="buttonActive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="restartDevice"
>
<v-icon start class="open-icon" size="large"> mdi-restart-alert </v-icon>
<span class="open-label">Reboot Device</span>
</v-btn>
</v-col>
<v-col cols="12" sm="6">
<v-btn
color="error"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="() => (showFactoryReset = true)"
>
<v-icon start class="open-icon" size="large"> mdi-trash-can-outline </v-icon>
<span class="open-icon"> Factory Reset </span>
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
<!-- Device metrics card -->
<v-col>
<v-card class="mb-3 rounded-12 fill-height d-flex flex-column justify-space-between" color="surface">
<v-card-title class="d-flex justify-space-between">
<span>Device Metrics</span>
</v-card-title>
<v-card-text class="pt-0 flex-0-0 pb-2">
<div class="d-flex justify-space-between pb-3">
<span>CPU Usage</span>
<span>{{ Math.round(cpuUsageData.at(-1)?.value ?? 0) }}%</span>
</div>
<Suspense>
<!-- Allows us to import echarts when it's actually needed -->
<MetricsChart id="chart" :data="cpuUsageData" type="percentage" :min="0" :max="100" color="blue" />
<template #fallback> Loading... </template>
</Suspense>
</v-card-text>
<v-card-text class="pt-0 flex-0-0 pb-2">
<div class="d-flex justify-space-between pb-3 pt-3">
<span>CPU Memory Usage</span>
<span>{{ Math.round(cpuMemoryUsageData.at(-1)?.value ?? 0) }}%</span>
</div>
<Suspense>
<!-- Allows us to import echarts when it's actually needed -->
<MetricsChart id="chart" :data="cpuMemoryUsageData" type="percentage" :min="0" :max="100" color="purple" />
<template #fallback> Loading... </template>
</Suspense>
</v-card-text>
<v-card-text class="pt-0 flex-0-0 pb-2">
<div class="d-flex justify-space-between pb-3 pt-3">
<span>CPU Temperature</span>
<span>{{ cpuTempData.at(-1)?.value == -1 ? "--- " : Math.round(cpuTempData.at(-1)?.value ?? 0) }}°C</span>
</div>
<Suspense>
<!-- Allows us to import echarts when it's actually needed -->
<MetricsChart id="chart" :data="cpuTempData" type="temperature" color="red" />
<template #fallback> Loading... </template>
</Suspense>
</v-card-text>
<v-card-text class="pt-0 flex-0-0">
<div class="d-flex justify-space-between pb-3 pt-3">
<tooltipped-label
label="Network Usage"
icon="mdi-information"
location="top"
tooltip="Measured rate for this coprocessor ONLY. This FMS limit is for ALL robot communication. If you are experiencing bandwidth issues while under this limit, check other sources."
/>
<span
>{{ networkUsageData.at(-1)?.value == -1 ? "---" : networkUsageData.at(-1)?.value.toFixed(3) }} Mb/s</span
>
</div>
<Suspense>
<!-- Allows us to import echarts when it's actually needed -->
<MetricsChart id="chart" :data="networkUsageData" type="mb" :min="0" :max="10" color="green" />
<template #fallback> Loading... </template>
</Suspense>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Factory reset modal -->
<pv-delete-modal
v-model="showFactoryReset"
title="Factory Reset PhotonVision"
description="This will delete all settings and configurations stored on this device, including network settings. This action cannot be undone."
expected-confirmation-text="Delete Everything"
:on-confirm="nukePhotonConfigDirectory"
:on-backup="openExportSettingsPrompt"
delete-text="Factory reset"
/>
<!-- Import settings modal -->
<v-dialog
v-model="showImportDialog"
width="600"
@update:modelValue="
() => {
importType = undefined;
importFile = null;
}
"
>
<v-card color="surface" dark>
<v-card-title class="pb-0">Import Settings</v-card-title>
<v-card-text>
Upload and apply previously saved or exported PhotonVision settings to this device
<div class="pa-5 pb-0">
<pv-select
v-model="importType"
label="Type"
tooltip="Select the type of settings file you are trying to upload"
:items="['All Settings', 'Hardware Config', 'Hardware Settings', 'Network Config', 'Apriltag Layout']"
:select-cols="10"
style="width: 100%"
/>
<v-file-input
v-model="importFile"
class="pb-5"
variant="underlined"
:disabled="importType === undefined"
:error-messages="importType === undefined ? 'Settings type not selected' : ''"
:accept="importType === ImportType.AllSettings ? '.zip' : '.json'"
/>
<v-btn
color="primary"
:disabled="importFile === null"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="handleSettingsImport"
>
<v-icon start class="open-icon"> mdi-import </v-icon>
<span class="open-label">Import Settings</span>
</v-btn>
</div>
</v-card-text>
</v-card>
</v-dialog>
<a
ref="exportSettings"
style="color: black; text-decoration: none; display: none"
:href="`http://${address}/api/settings/photonvision_config.zip`"
download="photonvision-settings.zip"
target="_blank"
/>
</template>
<style scoped lang="scss">
.v-btn:not(.refresh) {
width: 100%;
}
.fill-height {
height: calc(100% - 12px) !important;
}
@media only screen and (max-width: 351px) {
.open-icon {
margin: 0 !important;
}
.open-label {
display: none;
}
}
</style>

View File

@@ -1,303 +0,0 @@
<script setup lang="ts">
import { inject, ref } from "vue";
import { useStateStore } from "@/stores/StateStore";
import PvSelect from "@/components/common/pv-select.vue";
import PvDeleteModal from "@/components/common/pv-delete-modal.vue";
import { useTheme } from "vuetify";
import { axiosPost } from "@/lib/PhotonUtils";
const theme = useTheme();
const restartProgram = () => {
axiosPost("/utils/restartProgram", "restart PhotonVision");
};
const restartDevice = () => {
axiosPost("/utils/restartDevice", "restart the device");
};
const address = inject<string>("backendHost");
const offlineUpdate = ref();
const openOfflineUpdatePrompt = () => {
offlineUpdate.value.click();
};
const handleOfflineUpdate = () => {
const files = offlineUpdate.value.files;
if (files.length === 0) return;
const formData = new FormData();
formData.append("jarData", files[0]);
useStateStore().showSnackbarMessage({
message: "New Software Upload in Progress...",
color: "secondary",
timeout: -1
});
axiosPost("/utils/offlineUpdate", "upload new software", formData, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: ({ progress }) => {
const uploadPercentage = (progress || 0) * 100.0;
if (uploadPercentage < 99.5) {
useStateStore().showSnackbarMessage({
message: "New Software Upload in Progress",
color: "secondary",
timeout: -1,
progressBar: uploadPercentage,
progressBarColor: "primary"
});
} else {
useStateStore().showSnackbarMessage({
message: "Installing uploaded software...",
color: "secondary",
timeout: -1
});
}
}
});
};
const exportLogFile = ref();
const openExportLogsPrompt = () => {
exportLogFile.value.click();
};
const exportSettings = ref();
const openExportSettingsPrompt = () => {
exportSettings.value.click();
};
enum ImportType {
AllSettings,
HardwareConfig,
HardwareSettings,
NetworkConfig,
ApriltagFieldLayout
}
const showImportDialog = ref(false);
const importType = ref<ImportType | undefined>(undefined);
const importFile = ref<File | null>(null);
const handleSettingsImport = () => {
if (importType.value === undefined || importFile.value === null) return;
const formData = new FormData();
formData.append("data", importFile.value);
let settingsEndpoint: string;
switch (importType.value) {
case ImportType.HardwareConfig:
settingsEndpoint = "/hardwareConfig";
break;
case ImportType.HardwareSettings:
settingsEndpoint = "/hardwareSettings";
break;
case ImportType.NetworkConfig:
settingsEndpoint = "/networkConfig";
break;
case ImportType.ApriltagFieldLayout:
settingsEndpoint = "/aprilTagFieldLayout";
break;
default:
case ImportType.AllSettings:
settingsEndpoint = "";
break;
}
axiosPost(`/settings${settingsEndpoint}`, "import settings", formData, {
headers: { "Content-Type": "multipart/form-data" }
});
showImportDialog.value = false;
importType.value = undefined;
importFile.value = null;
};
const showFactoryReset = ref(false);
const nukePhotonConfigDirectory = () => {
axiosPost("/utils/nukeConfigDirectory", "delete the config directory");
};
</script>
<template>
<v-card class="mb-3 rounded-12" color="surface">
<v-card-title>Device Control</v-card-title>
<div class="pa-5 pt-0">
<v-row>
<v-col cols="12" lg="4" md="6">
<v-btn
color="buttonActive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="restartProgram"
>
<v-icon start class="open-icon" size="large"> mdi-restart </v-icon>
<span class="open-label">Restart PhotonVision</span>
</v-btn>
</v-col>
<v-col cols="12" lg="4" md="6">
<v-btn
color="buttonActive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="restartDevice"
>
<v-icon start class="open-icon" size="large"> mdi-restart-alert </v-icon>
<span class="open-label">Restart Device</span>
</v-btn>
</v-col>
<v-col cols="12" lg="4">
<v-btn
color="buttonPassive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="openOfflineUpdatePrompt"
>
<v-icon start class="open-icon" size="large"> mdi-upload </v-icon>
<span class="open-label">Offline Update</span>
</v-btn>
<input ref="offlineUpdate" type="file" accept=".jar" style="display: none" @change="handleOfflineUpdate" />
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6">
<v-btn
color="buttonPassive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="() => (showImportDialog = true)"
>
<v-icon start class="open-icon" size="large"> mdi-import </v-icon>
<span class="open-label">Import Settings</span>
</v-btn>
<v-dialog
v-model="showImportDialog"
width="600"
@update:modelValue="
() => {
importType = undefined;
importFile = null;
}
"
>
<v-card color="surface" dark>
<v-card-title class="pb-0">Import Settings</v-card-title>
<v-card-text>
Upload and apply previously saved or exported PhotonVision settings to this device
<div class="pa-5 pb-0">
<pv-select
v-model="importType"
label="Type"
tooltip="Select the type of settings file you are trying to upload"
:items="[
'All Settings',
'Hardware Config',
'Hardware Settings',
'Network Config',
'Apriltag Layout'
]"
:select-cols="10"
style="width: 100%"
/>
<v-file-input
v-model="importFile"
class="pb-5"
variant="underlined"
:disabled="importType === undefined"
:error-messages="importType === undefined ? 'Settings type not selected' : ''"
:accept="importType === ImportType.AllSettings ? '.zip' : '.json'"
/>
<v-btn
color="primary"
:disabled="importFile === null"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="handleSettingsImport"
>
<v-icon start class="open-icon"> mdi-import </v-icon>
<span class="open-label">Import Settings</span>
</v-btn>
</div>
</v-card-text>
</v-card>
</v-dialog>
</v-col>
<v-col cols="12" sm="6">
<v-btn
color="buttonPassive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="openExportSettingsPrompt"
>
<v-icon start class="open-icon" size="large"> mdi-export </v-icon>
<span class="open-label">Export Settings</span>
</v-btn>
<a
ref="exportSettings"
style="color: black; text-decoration: none; display: none"
:href="`http://${address}/api/settings/photonvision_config.zip`"
download="photonvision-settings.zip"
target="_blank"
/>
</v-col>
<v-col cols="12" sm="6">
<v-btn
color="buttonPassive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="openExportLogsPrompt"
>
<v-icon start class="open-icon" size="large"> mdi-download </v-icon>
<span class="open-label">Download logs</span>
<!-- Special hidden link that gets 'clicked' when the user exports journalctl logs -->
<a
ref="exportLogFile"
style="color: black; text-decoration: none; display: none"
:href="`http://${address}/api/utils/photonvision-journalctl.txt`"
download="photonvision-journalctl.txt"
target="_blank"
/>
</v-btn>
</v-col>
<v-col cols="12" sm="6">
<v-btn
color="buttonPassive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="useStateStore().showLogModal = true"
>
<v-icon start class="open-icon" size="large"> mdi-eye </v-icon>
<span class="open-label">View logs</span>
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-btn
color="error"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="() => (showFactoryReset = true)"
>
<v-icon start class="open-icon" size="large"> mdi-trash-can-outline </v-icon>
<span class="open-icon"> Factory Reset PhotonVision </span>
</v-btn>
</v-col>
</v-row>
</div>
<pv-delete-modal
v-model="showFactoryReset"
title="Factory Reset PhotonVision"
description="This will delete all settings and configurations stored on this device, including network settings. This action cannot be undone."
expected-confirmation-text="Delete Everything"
:on-confirm="nukePhotonConfigDirectory"
:on-backup="openExportSettingsPrompt"
delete-text="Factory reset"
/>
</v-card>
</template>
<style scoped>
.v-btn {
width: 100%;
}
@media only screen and (max-width: 351px) {
.open-icon {
margin: 0 !important;
}
.open-label {
display: none;
}
}
</style>

View File

@@ -9,6 +9,7 @@ import { type ConfigurableNetworkSettings, NetworkConnectionType } from "@/types
import { useStateStore } from "@/stores/StateStore";
import { useTheme } from "vuetify";
import { getThemeColor, setThemeColor, resetTheme } from "@/lib/ThemeManager";
import { statusCheck } from "@/lib/PhotonUtils";
const theme = useTheme();
@@ -80,9 +81,7 @@ const settingsHaveChanged = (): boolean => {
);
};
const saveGeneralSettings = () => {
const changingStaticIp = useSettingsStore().network.connectionType === NetworkConnectionType.Static;
const saveGeneralSettings = async () => {
// replace undefined members with empty strings for backend
const payload = {
connectionType: tempSettingsStruct.value.connectionType,
@@ -97,42 +96,58 @@ const saveGeneralSettings = () => {
staticIp: tempSettingsStruct.value.staticIp
};
useSettingsStore()
.updateGeneralSettings(payload)
.then((response) => {
useStateStore().showSnackbarMessage({ message: response.data.text || response.data, color: "success" });
const changingStaticIP =
useSettingsStore().network.connectionType === NetworkConnectionType.Static &&
tempSettingsStruct.value.staticIp !== useSettingsStore().network.staticIp;
// Update the local settings cause the backend checked their validity. Assign is to deref value
useSettingsStore().network = { ...useSettingsStore().network, ...Object.assign({}, tempSettingsStruct.value) };
})
.catch((error) => {
resetTempSettingsStruct();
if (error.response) {
if (error.status === 504 || changingStaticIp) {
useStateStore().showSnackbarMessage({
color: "error",
message: `Connection lost! Try the new static IP at ${useSettingsStore().network.staticIp}:5800 or ${
useSettingsStore().network.hostname
}:5800?`
});
} else {
useStateStore().showSnackbarMessage({
color: "error",
message: error.response.data.text || error.response.data
});
}
} else if (error.request) {
useStateStore().showSnackbarMessage({
color: "error",
message: "Error while trying to process the request! The backend didn't respond."
});
} else {
useStateStore().showSnackbarMessage({
color: "error",
message: "An error occurred while trying to process the request."
});
}
});
try {
const response = await useSettingsStore().updateGeneralSettings(payload);
useStateStore().showSnackbarMessage({ message: response.data.text || response.data, color: "success" });
// Update the local settings cause the backend checked their validity. Assign is to deref value
useSettingsStore().network = { ...useSettingsStore().network, ...Object.assign({}, tempSettingsStruct.value) };
} catch (error: any) {
resetTempSettingsStruct();
if (error.response) {
useStateStore().showSnackbarMessage({
color: "error",
message: error.response.data.text || error.response.data
});
} else if (error.request) {
useStateStore().showSnackbarMessage({
color: "error",
message: "Error while trying to process the request! The backend didn't respond."
});
} else {
useStateStore().showSnackbarMessage({
color: "error",
message: "An error occurred while trying to process the request."
});
}
return;
}
if (changingStaticIP) {
const status = await statusCheck(5000, tempSettingsStruct.value.staticIp);
if (!status) {
useStateStore().showSnackbarMessage({
message:
"Warning: Unable to verify new static IP address! You may need to manually navigate to the new address: http://" +
tempSettingsStruct.value.staticIp +
":5800",
color: "warning"
});
return;
}
// Keep current hash route (e.g., #/settings)
const hash = window.location.hash || "";
const url = `http://${tempSettingsStruct.value.staticIp}:5800/${hash}`;
setTimeout(() => {
window.location.href = url;
}, 1000);
}
};
const currentNetworkInterfaceIndex = computed<number | undefined>({

View File

@@ -1,292 +0,0 @@
<script setup lang="ts">
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
import { computed, onBeforeMount, ref } from "vue";
import { useStateStore } from "@/stores/StateStore";
interface MetricItem {
header: string;
value?: string;
}
const generalMetrics = computed<MetricItem[]>(() => {
const stats = [
{ header: "Version", value: useSettingsStore().general.version || "Unknown" },
{ header: "Hardware Model", value: useSettingsStore().general.hardwareModel || "Unknown" },
{ header: "Platform", value: useSettingsStore().general.hardwarePlatform || "Unknown" },
{ header: "GPU Acceleration", value: useSettingsStore().general.gpuAcceleration || "Unknown" }
];
if (!useSettingsStore().network.networkingDisabled) {
stats.push({ header: "IP Address", value: useSettingsStore().metrics.ipAddress || "Unknown" });
}
return stats;
});
// @ts-expect-error This uses Intl.DurationFormat which is newly implemented and not available in TS.
const durationFormatter = new Intl.DurationFormat("en", { style: "narrow" });
const platformMetrics = computed<MetricItem[]>(() => {
const metrics = useSettingsStore().metrics;
const stats = [
{
header: "CPU Temp",
value: metrics.cpuTemp === undefined || metrics.cpuTemp == -1 ? "Unknown" : `${metrics.cpuTemp}°C`
},
{
header: "CPU Usage",
value: metrics.cpuUtil === undefined ? "Unknown" : `${metrics.cpuUtil}%`
},
{
header: "CPU Memory Usage",
value:
metrics.ramUtil && metrics.ramMem && metrics.ramUtil >= 0 && metrics.ramMem >= 0
? `${metrics.ramUtil}MB of ${metrics.ramMem}MB`
: "Unknown"
},
{
header: "Uptime",
value: (() => {
const seconds = metrics.uptime;
if (seconds === undefined) return "Unknown";
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return durationFormatter.format({
days: days,
hours: hours,
minutes: minutes,
seconds: secs
});
})()
},
{
header: "Disk Usage",
value: metrics.diskUtilPct === undefined ? "Unknown" : `${metrics.diskUtilPct}%`
}
];
if (metrics.npuUsage && metrics.npuUsage.length > 0) {
stats.push({
header: "NPU Usage",
value: metrics.npuUsage?.map((usage, index) => `Core${index} ${usage}%`).join(", ") || "Unknown"
});
}
if (metrics.gpuMem && metrics.gpuMemUtil && metrics.gpuMem > 0 && metrics.gpuMemUtil > 0) {
stats.push({
header: "GPU Memory Usage",
value: `${metrics.gpuMemUtil}MB of ${metrics.gpuMem}MB`
});
}
if (metrics.cpuThr) {
stats.push({
header: "CPU Throttling",
value: metrics.cpuThr.toString()
});
}
return stats;
});
const metricsLastFetched = ref("Never");
const fetchMetrics = () => {
useSettingsStore()
.requestMetricsUpdate()
.catch((error) => {
if (error.request) {
useStateStore().showSnackbarMessage({
color: "error",
message: "Unable to fetch metrics! The backend didn't respond."
});
} else {
useStateStore().showSnackbarMessage({
color: "error",
message: "An error occurred while trying to fetch metrics."
});
}
})
.finally(() => {
const pad = (num: number): string => {
return String(num).padStart(2, "0");
};
const date = new Date();
metricsLastFetched.value = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
});
};
onBeforeMount(() => {
fetchMetrics();
});
</script>
<template>
<v-card class="mb-3 rounded-12" color="surface">
<v-card-title style="display: flex; justify-content: space-between">
<span>Metrics</span>
<v-btn variant="text" @click="fetchMetrics">
<v-icon start class="open-icon" size="large">mdi-reload</v-icon>
Last Fetched: {{ metricsLastFetched }}
</v-btn>
</v-card-title>
<v-card-text class="pt-0 pb-3">
<v-card-subtitle class="pa-0" style="font-size: 16px">General</v-card-subtitle>
<v-table class="metrics-table mt-3">
<thead>
<tr>
<th
v-for="(item, itemIndex) in generalMetrics"
:key="itemIndex"
class="metric-item metric-item-title"
:class="{
tl: itemIndex === 0,
tr: itemIndex === generalMetrics.length - 1,
t: 0 < itemIndex && itemIndex < generalMetrics.length - 1
}"
>
{{ item.header }}
</th>
</tr>
</thead>
<tbody>
<tr>
<td
v-for="(item, itemIndex) in generalMetrics"
:key="itemIndex"
class="metric-item"
:class="{
bl: itemIndex === 0,
br: itemIndex === generalMetrics.length - 1,
b: 0 < itemIndex && itemIndex < generalMetrics.length - 1
}"
>
{{ item.value }}
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
<v-card-text class="pt-4">
<v-card-subtitle class="pa-0 pb-1" style="font-size: 16px">Hardware</v-card-subtitle>
<v-table class="metrics-table mt-3">
<thead>
<tr>
<th
v-for="(item, itemIndex) in platformMetrics"
:key="itemIndex"
class="metric-item metric-item-title"
:class="{
tl: itemIndex === 0,
tr: itemIndex === platformMetrics.length - 1,
t: 0 < itemIndex && itemIndex < platformMetrics.length - 1
}"
>
{{ item.header }}
</th>
</tr>
</thead>
<tbody>
<tr>
<td
v-for="(item, itemIndex) in platformMetrics"
:key="itemIndex"
class="metric-item"
:class="{
bl: itemIndex === 0,
br: itemIndex === platformMetrics.length - 1,
b: 0 < itemIndex && itemIndex < platformMetrics.length - 1
}"
>
<span v-if="useSettingsStore().metrics.cpuUtil !== undefined">{{ item.value }}</span>
<span v-else>---</span>
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
</v-card>
</template>
<style scoped lang="scss">
.metrics-table {
width: 100%;
text-align: center;
}
$stats-table-border: rgba(255, 255, 255, 0.5);
$stats-table-inner: rgba(255, 255, 255, 0.1);
.t {
border-top: 1px solid $stats-table-border;
border-right: 1px solid $stats-table-border;
border-bottom: 1px solid $stats-table-inner !important;
}
.b {
border-bottom: 1px solid $stats-table-border;
border-right: 1px solid $stats-table-border;
}
.tl {
border-top: 1px solid $stats-table-border;
border-left: 1px solid $stats-table-border;
border-right: 1px solid $stats-table-border;
border-bottom: 1px solid $stats-table-inner !important;
border-top-left-radius: 5px;
}
.tr {
border-top: 1px solid $stats-table-border;
border-right: 1px solid $stats-table-border;
border-bottom: 1px solid $stats-table-inner !important;
border-top-right-radius: 5px;
}
.bl {
border-bottom: 1px solid $stats-table-border;
border-left: 1px solid $stats-table-border;
border-right: 1px solid $stats-table-border;
border-bottom-left-radius: 5px;
}
.br {
border-bottom: 1px solid $stats-table-border;
border-right: 1px solid $stats-table-border;
border-bottom-right-radius: 5px;
}
.metric-item {
font-size: 16px !important;
padding: 1px 15px 1px 10px;
border-right: 1px solid $stats-table-border;
font-weight: normal;
color: white !important;
text-align: center !important;
}
.metric-item-title {
font-size: 18px !important;
}
.v-table {
::-webkit-scrollbar {
width: 0;
height: 0.55em;
border-radius: 5px;
}
::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background-color: rgb(var(--v-theme-accent));
border-radius: 10px;
}
}
</style>

View File

@@ -0,0 +1,236 @@
<script setup lang="ts">
import { onMounted, ref, onBeforeUnmount, watch } from "vue";
import { useTheme } from "vuetify";
// Color - original (adjusted)
// blue - 59, 130, 246 (r: 92, g: 154, b: 255)
// purple - 154, 100, 180 (r: 167, g: 104, b: 196)
// green - 65, 181, 127 (r: 75, g: 209, b: 147)
// red - 238, 102, 102 (r: 238, g: 102, b: 102)
const colors = {
"blue-LightTheme": { r: 255, g: 216, b: 67 },
"blue-DarkTheme": { r: 92, g: 154, b: 255 },
"purple-LightTheme": { r: 255, g: 216, b: 67 },
"purple-DarkTheme": { r: 167, g: 104, b: 196 },
"red-LightTheme": { r: 255, g: 216, b: 67 },
"red-DarkTheme": { r: 238, g: 102, b: 102 },
"green-LightTheme": { r: 255, g: 216, b: 67 },
"green-DarkTheme": { r: 75, g: 209, b: 147 }
};
const DEFAULT_COLOR = "blue";
const typeLabels = {
percentage: "%",
temperature: "°C",
mb: " Mb/s"
};
const theme = useTheme();
const chartRef = ref(null);
let chart: echarts.ECharts | null = null;
const getOptions = (data: ChartData[] = []) => {
const now = Date.now();
return {
title: {
show: false
},
tooltip: {
trigger: "axis",
formatter: (params: any) => {
const p = params[0];
const append = typeLabels[props.type];
const fmsLimitLabel = "FMS Limit - 7.000 Mb/s";
// prettier-ignore
let tooltip = "<div style=\"text-align: right;\">";
const seriesData = `${new Date(p.value[0]).toLocaleTimeString([], { hour12: false })} - ${p.value[1].toFixed(props.type === "mb" ? 3 : 2)}${append}`;
if (props.type === "mb") {
if (p.value[1] >= 7) tooltip += seriesData + `<br/>${fmsLimitLabel}`;
else tooltip += fmsLimitLabel + `<br/>${seriesData}`;
} else tooltip += seriesData;
return `${tooltip}</div>`;
},
backgroundColor: theme.themes.value[theme.global.name.value].colors.background,
textStyle: {
color: theme.themes.value[theme.global.name.value].colors.onBackground
},
axisPointer: {
animation: false
}
},
grid: {
top: 0,
bottom: 10,
left: 0,
right: 50,
containLabel: false
},
xAxis: {
type: "time",
splitLine: {
show: true,
lineStyle: {
color: "#ffffff18"
}
},
splitNumber: 4,
min: now - 55 * 1000,
axisLine: {
lineStyle: {
color: theme.global.name.value === "LightTheme" ? "#aaa" : "#777"
}
},
axisLabel: {
align: "left",
color: theme.global.name.value === "LightTheme" ? "#fff" : "#ddd",
formatter: (value: number) => {
const date = new Date(value);
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
});
}
}
},
yAxis: {
type: "value",
position: "right",
min:
props.min ??
function (value) {
return Math.max(0, (value.min - 10) | 0);
},
max:
props.max ??
function (value) {
return (value.max + 10) | 0;
},
splitNumber: 2,
splitLine: {
show: true,
lineStyle: {
color: "#ffffff18"
}
},
axisLabel: {
color: theme.global.name.value === "LightTheme" ? "#fff" : "#ddd"
}
},
series: getSeries(data),
animation: false
};
};
const getSeries = (data: ChartData[] = []) => {
const color = colors[`${props.color ?? DEFAULT_COLOR}-${theme.global.name.value}`];
return [
{
type: "line",
showSymbol: false,
data: data.map((d) => [d.time, d.value]),
markLine:
props.type === "mb"
? {
symbol: "none",
lineStyle: {
color: "red",
width: 1
},
label: {
show: false
},
data: [{ yAxis: 7 }]
}
: null,
lineStyle: {
width: 1.5,
color:
theme.global.name.value === "LightTheme"
? theme.themes.value[theme.global.name.value].colors.primary
: `rgb(${color.r}, ${color.g}, ${color.b})`
},
areaStyle: {
color: {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color:
theme.global.name.value === "LightTheme"
? `${theme.themes.value[theme.global.name.value].colors.primary}40`
: `rgba(${color.r}, ${color.g}, ${color.b}, 0.15)`
},
{
offset: 1,
color:
theme.global.name.value === "LightTheme"
? `${theme.themes.value[theme.global.name.value].colors.primary}40`
: `rgba(${color.r}, ${color.g}, ${color.b}, 0.15)`
}
]
}
}
}
];
};
interface ChartData {
time: number;
value: number;
}
// Type options: "percentage", "temperature", "mb"
const props = defineProps<{
data: ChartData[];
type: string;
min?: number;
max?: number;
color?: string;
}>();
onMounted(async () => {
const echarts = await import("echarts");
chart = echarts.init(chartRef.value);
chart.setOption(getOptions(props.data));
window.addEventListener("resize", resizeChart);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", resizeChart);
chart?.dispose();
});
function resizeChart() {
chart?.resize();
}
watch(
() => props.data,
(data) => {
chart?.setOption(getOptions(data));
},
{ deep: true }
);
</script>
<template>
<div ref="chartRef"></div>
</template>
<style scoped>
div {
width: calc(100% + 20px);
height: 100px;
margin-right: -20px;
}
</style>

View File

@@ -43,25 +43,27 @@ const handleImport = async () => {
timeout: -1
});
axiosPost("/objectdetection/import", "import an object detection model", formData, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: ({ progress }) => {
const uploadPercentage = (progress || 0) * 100.0;
if (uploadPercentage < 99.5) {
useStateStore().showSnackbarMessage({
message: "Object Detection Model Upload in Process, " + uploadPercentage.toFixed(2) + "% complete",
color: "secondary",
timeout: -1
});
} else {
useStateStore().showSnackbarMessage({
message: "Processing uploaded Object Detection Model...",
color: "secondary",
timeout: -1
});
if (
await axiosPost("/objectdetection/import", "import an object detection model", formData, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: ({ progress }) => {
const uploadPercentage = (progress || 0) * 100.0;
if (uploadPercentage < 99.5) {
useStateStore().showSnackbarMessage({
message: "Object Detection Model Upload in Process, " + uploadPercentage.toFixed(2) + "% complete",
color: "secondary",
timeout: -1
});
}
}
}
});
})
) {
useStateStore().showSnackbarMessage({
message: "Processing uploaded Object Detection Model...",
color: "secondary",
timeout: -1
});
}
showImportDialog.value = false;
@@ -72,13 +74,13 @@ const handleImport = async () => {
importVersion.value = null;
};
const deleteModel = async (model: ObjectDetectionModelProperties) => {
const deleteModel = (model: ObjectDetectionModelProperties) => {
axiosPost("/objectdetection/delete", "delete an object detection model", {
modelPath: model.modelPath
});
};
const renameModel = async (model: ObjectDetectionModelProperties, newName: string) => {
const renameModel = (model: ObjectDetectionModelProperties, newName: string) => {
useStateStore().showSnackbarMessage({
message: "Renaming Object Detection Model...",
color: "secondary",
@@ -121,33 +123,35 @@ const nukeModels = () => {
const showBulkImportDialog = ref(false);
const importFile = ref<File | null>(null);
const handleBulkImport = () => {
const handleBulkImport = async () => {
if (importFile.value === null) return;
const formData = new FormData();
formData.append("data", importFile.value);
axiosPost("/objectdetection/bulkimport", "import object detection models", formData, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: ({ progress }) => {
const uploadPercentage = (progress || 0) * 100.0;
if (uploadPercentage < 99.5) {
useStateStore().showSnackbarMessage({
message: "Object Detection Models Upload in Progress",
color: "secondary",
timeout: -1,
progressBar: uploadPercentage,
progressBarColor: "primary"
});
} else {
useStateStore().showSnackbarMessage({
message: "Importing New Object Detection Models...",
color: "secondary",
timeout: -1
});
if (
await axiosPost("/objectdetection/bulkimport", "import object detection models", formData, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: ({ progress }) => {
const uploadPercentage = (progress || 0) * 100.0;
if (uploadPercentage < 99.5) {
useStateStore().showSnackbarMessage({
message: "Object Detection Models Upload in Progress",
color: "secondary",
timeout: -1,
progressBar: uploadPercentage,
progressBarColor: "primary"
});
}
}
}
});
})
) {
useStateStore().showSnackbarMessage({
message: "Importing New Object Detection Models...",
color: "secondary",
timeout: -1
});
}
showImportDialog.value = false;
importFile.value = null;
};

View File

@@ -6,6 +6,49 @@ export const resolutionsAreEqual = (a: Resolution, b: Resolution) => {
return a.height === b.height && a.width === b.width;
};
/**
* Checks the status of the backend by polling the "/status" endpoint.
*
* This function will repeatedly attempt to send a GET request to the backend
* until a successful response is received or the specified timeout is reached.
*
* @param timeout - The maximum time in milliseconds to wait for a successful response.
* @param ip - Optional IP address of the backend server. If not provided, the default endpoint is used. This is meant for the case where the backend is running on a different IP than the frontend.
* @returns A promise that resolves to a boolean indicating whether the backend is responsive (true) or not (false).
*/
export const statusCheck = async (timeout: number, ip?: string): Promise<boolean> => {
// Poll the backend until it's responsive or we hit the timeout
let pollLimit = Math.floor(timeout / 100);
while (pollLimit > 0) {
try {
pollLimit--;
await axios.get(ip ? `http://${ip}/api/status` : "/status");
return true;
} catch {
// Backend not ready yet, wait and retry
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
return false;
};
/**
* Forces a page reload after a brief delay and a status check.
*/
export const forceReloadPage = async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
useStateStore().showSnackbarMessage({
message: "Reloading the page to apply changes...",
color: "success"
});
await statusCheck(20000);
window.location.reload();
};
export const getResolutionString = (resolution: Resolution): string => `${resolution.width}x${resolution.height}`;
export const parseJsonFile = async <T extends Record<string, any>>(file: File): Promise<T> => {
@@ -28,33 +71,33 @@ export const parseJsonFile = async <T extends Record<string, any>>(file: File):
* @param description A brief description of the request for users, e.g., "import object detection models".
* @param data Payload to be sent in the POST request
* @param config Optional axios request configuration
* @returns A promise that resolves when the POST request is complete
* @returns A promise that resolves to true if the POST request is successful, or false if an error occurs.
*/
export const axiosPost = (url: string, description: string, data?: any, config?: any): Promise<void> => {
return axios
.post(url, data, config)
.then(() => {
useStateStore().showSnackbarMessage({
message: "Successfully dispatched the request to " + description + ". Waiting for backend to respond",
color: "success"
});
})
.catch((error) => {
if (error.response) {
useStateStore().showSnackbarMessage({
message: "The backend is unable to fulfill the request to " + description + ".",
color: "error"
});
} else if (error.request) {
useStateStore().showSnackbarMessage({
message: "Error while trying to process the request to " + description + "! The backend didn't respond.",
color: "error"
});
} else {
useStateStore().showSnackbarMessage({
message: "An error occurred while trying to process the request to " + description + ".",
color: "error"
});
}
export const axiosPost = async (url: string, description: string, data?: any, config?: any): Promise<boolean> => {
try {
await axios.post(url, data, config);
useStateStore().showSnackbarMessage({
message: "Successfully dispatched the request to " + description + ". Waiting for backend to respond",
color: "success"
});
return true;
} catch (error: any) {
if (error.response) {
useStateStore().showSnackbarMessage({
message: "The backend is unable to fulfill the request to " + description + ".",
color: "error"
});
} else if (error.request) {
useStateStore().showSnackbarMessage({
message: "Error while trying to process the request to " + description + "! The backend didn't respond.",
color: "error"
});
} else {
useStateStore().showSnackbarMessage({
message: "An error occurred while trying to process the request to " + description + ".",
color: "error"
});
}
return false;
}
};

View File

@@ -0,0 +1,24 @@
import type { JsonMatOfDouble, Resolution } from "@/types/SettingTypes";
const three = import("three");
/**
* Convert a camera intrinsics matrix and image resolution to a Three.js PerspectiveCamera. This assumes no skew and square pixels (same focal length in x and y), which is a sane assumption for most FRC cameras
*
* @param resolution video mode width/height
* @param intrinsicsCore camera intrinsics from the backend, row-major
* @returns a Three.js PerspectiveCamera matching the provided intrinsics
*/
export const createPerspectiveCamera = async (
resolution: Resolution,
intrinsicsCore: JsonMatOfDouble,
frustumMax: number = 1
) => {
const { PerspectiveCamera } = await three;
const imageWidth = resolution.width;
const imageHeight = resolution.height;
const focalLengthY = intrinsicsCore.data[4];
const fovY = 2 * Math.atan(imageHeight / (2 * focalLengthY)) * (180 / Math.PI);
const aspect = imageWidth / imageHeight;
return new PerspectiveCamera(fovY, aspect, 0.1, frustumMax);
};

View File

@@ -16,6 +16,11 @@ export interface NTConnectionStatus {
clients?: number;
}
interface NetworkUsageEntry {
time: number;
usage: number;
}
interface StateStore {
backendConnected: boolean;
websocket?: AutoReconnectingWebsocket;
@@ -24,6 +29,7 @@ interface StateStore {
sidebarFolded: boolean;
logMessages: LogMessage[];
currentCameraUniqueName: string;
networkUsageHistory: NetworkUsageEntry[];
backendResults: Record<number, PipelineResult>;
multitagResultBuffer: Record<string, MultitagResult[]>;
@@ -64,6 +70,7 @@ export const useStateStore = defineStore("state", {
localStorage.getItem("sidebarFolded") === null ? false : localStorage.getItem("sidebarFolded") === "true",
logMessages: [],
currentCameraUniqueName: Object.keys(cameraStore.cameras)[0],
networkUsageHistory: [],
backendResults: {
0: {

View File

@@ -91,6 +91,9 @@ export const useCameraSettingsStore = defineStore("cameraSettings", {
maxWhiteBalanceTemp(): number {
return this.currentCameraSettings.maxWhiteBalanceTemp;
},
fpsLimit(): number {
return this.currentCameraSettings.fpsLimit;
},
isConnected(): boolean {
return this.currentCameraSettings.isConnected;
},
@@ -141,6 +144,7 @@ export const useCameraSettingsStore = defineStore("cameraSettings", {
minWhiteBalanceTemp: d.minWhiteBalanceTemp,
maxWhiteBalanceTemp: d.maxWhiteBalanceTemp,
matchedCameraInfo: d.matchedCameraInfo,
fpsLimit: d.fpsLimit,
isConnected: d.isConnected,
hasConnected: d.hasConnected,
mismatch: d.mismatch

View File

@@ -10,6 +10,7 @@ import { NetworkConnectionType } from "@/types/SettingTypes";
import { useStateStore } from "@/stores/StateStore";
import axios from "axios";
import type { WebsocketSettingsUpdate } from "@/types/WebsocketDataTypes";
import { ref } from "vue";
interface GeneralSettingsStore {
general: GeneralSettings;
@@ -19,6 +20,50 @@ interface GeneralSettingsStore {
currentFieldLayout;
}
interface MetricsEntry {
time: number;
metrics: MetricData;
}
class MetricsHistory {
private MAX_METRIC_HISTORY = 60;
private UPDATE_INTERVAL_MS = 900;
private buffer: (MetricsEntry | undefined)[];
private size: number;
private index = 0;
private count = 0;
private lastUpdate = 0;
constructor(size = this.MAX_METRIC_HISTORY) {
this.size = size;
this.buffer = new Array<MetricsEntry | undefined>(size);
}
update(value: MetricsEntry): boolean {
const now = Date.now();
if (now - this.lastUpdate < this.UPDATE_INTERVAL_MS) return false;
this.lastUpdate = now;
this.buffer[this.index] = value;
this.index = (this.index + 1) % this.size;
this.count = Math.min(this.count + 1, this.size);
return true;
}
getHistory(): MetricsEntry[] {
const result: MetricsEntry[] = new Array(this.count);
for (let i = 0; i < this.count; i++) {
const idx = (this.index - this.count + i + this.size) % this.size;
result[i] = this.buffer[idx]!;
}
return result;
}
}
const metricsHistoryBuffer = new MetricsHistory();
export const metricsHistorySnapshot = ref<MetricsEntry[]>([]);
export const useSettingsStore = defineStore("settings", {
state: (): GeneralSettingsStore => ({
general: {
@@ -62,9 +107,12 @@ export const useSettingsStore = defineStore("settings", {
gpuMem: undefined,
gpuMemUtil: undefined,
diskUtilPct: undefined,
diskUsableSpace: undefined,
npuUsage: undefined,
ipAddress: undefined,
uptime: undefined
uptime: undefined,
sentBitRate: undefined,
recvBitRate: undefined
},
currentFieldLayout: {
field: {
@@ -83,9 +131,6 @@ export const useSettingsStore = defineStore("settings", {
}
},
actions: {
requestMetricsUpdate() {
return axios.post("/utils/publishMetrics");
},
updateMetricsFromWebsocket(data: Required<MetricData>) {
this.metrics = {
cpuTemp: data.cpuTemp || undefined,
@@ -96,10 +141,16 @@ export const useSettingsStore = defineStore("settings", {
gpuMem: data.gpuMem || undefined,
gpuMemUtil: data.gpuMemUtil || undefined,
diskUtilPct: data.diskUtilPct || undefined,
diskUsableSpace: data.diskUsableSpace || undefined,
npuUsage: data.npuUsage || undefined,
ipAddress: data.ipAddress || undefined,
uptime: data.uptime || undefined
uptime: data.uptime || undefined,
sentBitRate: data.sentBitRate || undefined,
recvBitRate: data.recvBitRate || undefined
};
if (metricsHistoryBuffer.update({ time: Date.now(), metrics: this.metrics })) {
metricsHistorySnapshot.value = metricsHistoryBuffer.getHistory();
}
},
updateGeneralSettingsFromWebsocket(data: WebsocketSettingsUpdate) {
this.general = {

View File

@@ -66,7 +66,7 @@ export interface PipelineSettings {
hsvHue: WebsocketNumberPair | [number, number];
ledMode: boolean;
hueInverted: boolean;
outputShowMultipleTargets: boolean;
outputMaximumTargets: number;
contourSortMode: number;
cameraExposureRaw: number;
cameraMinExposureRaw: number;
@@ -84,6 +84,8 @@ export interface PipelineSettings {
cameraAutoWhiteBalance: boolean;
cameraWhiteBalanceTemp: number;
blockForFrames: boolean;
}
export type ConfigurablePipelineSettings = Partial<
Omit<
@@ -106,7 +108,7 @@ export type ConfigurablePipelineSettings = Partial<
// Omitted settings are changed for all pipeline types
export const DefaultPipelineSettings: Omit<
PipelineSettings,
"cameraGain" | "targetModel" | "ledMode" | "outputShowMultipleTargets" | "cameraExposureRaw" | "pipelineType"
"cameraGain" | "targetModel" | "ledMode" | "cameraExposureRaw" | "pipelineType"
> = {
offsetRobotOffsetMode: RobotOffsetPointMode.None,
streamingFrameDivisor: 0,
@@ -135,6 +137,7 @@ export const DefaultPipelineSettings: Omit<
offsetDualPointB: { x: 0, y: 0 },
hsvHue: { first: 50, second: 180 },
hueInverted: false,
outputMaximumTargets: 20,
contourSortMode: 0,
offsetSinglePoint: { x: 0, y: 0 },
cameraBrightness: 50,
@@ -148,7 +151,8 @@ export const DefaultPipelineSettings: Omit<
cameraAutoWhiteBalance: false,
cameraWhiteBalanceTemp: 4000,
cameraMinExposureRaw: 1,
cameraMaxExposureRaw: 2
cameraMaxExposureRaw: 2,
blockForFrames: true
};
export interface ReflectivePipelineSettings extends PipelineSettings {
@@ -163,7 +167,7 @@ export const DefaultReflectivePipelineSettings: ReflectivePipelineSettings = {
cameraGain: 20,
targetModel: TargetModel.InfiniteRechargeHighGoalOuter,
ledMode: true,
outputShowMultipleTargets: false,
outputMaximumTargets: 20,
cameraExposureRaw: 6,
pipelineType: PipelineType.Reflective,
@@ -194,7 +198,7 @@ export const DefaultColoredShapePipelineSettings: ColoredShapePipelineSettings =
cameraGain: 75,
targetModel: TargetModel.InfiniteRechargeHighGoalOuter,
ledMode: true,
outputShowMultipleTargets: false,
outputMaximumTargets: 20,
cameraExposureRaw: 20,
pipelineType: PipelineType.ColoredShape,
@@ -234,10 +238,9 @@ export const DefaultAprilTagPipelineSettings: AprilTagPipelineSettings = {
cameraGain: 75,
targetModel: TargetModel.AprilTag6p5in_36h11,
ledMode: false,
outputShowMultipleTargets: true,
outputMaximumTargets: 127,
cameraExposureRaw: 20,
pipelineType: PipelineType.AprilTag,
hammingDist: 0,
numIterations: 40,
decimate: 1,
@@ -275,13 +278,12 @@ export type ConfigurableArucoPipelineSettings = Partial<Omit<ArucoPipelineSettin
export const DefaultArucoPipelineSettings: ArucoPipelineSettings = {
...DefaultPipelineSettings,
cameraGain: 75,
outputShowMultipleTargets: true,
outputMaximumTargets: 127,
targetModel: TargetModel.AprilTag6p5in_36h11,
cameraExposureRaw: -1,
cameraAutoExposure: true,
ledMode: false,
pipelineType: PipelineType.Aruco,
tagFamily: AprilTagFamily.Family36h11,
threshWinSizes: { first: 11, second: 91 },
threshStepSize: 40,
@@ -313,7 +315,7 @@ export const DefaultObjectDetectionPipelineSettings: ObjectDetectionPipelineSett
cameraGain: 20,
targetModel: TargetModel.InfiniteRechargeHighGoalOuter,
ledMode: true,
outputShowMultipleTargets: false,
outputMaximumTargets: 20,
cameraExposureRaw: 6,
confidence: 0.9,
nms: 0.45,
@@ -332,7 +334,7 @@ export const DefaultCalibration3dPipelineSettings: Calibration3dPipelineSettings
cameraGain: 20,
targetModel: TargetModel.InfiniteRechargeHighGoalOuter,
ledMode: true,
outputShowMultipleTargets: false,
outputMaximumTargets: 1,
cameraExposureRaw: 6,
drawAllSnapshots: false
};

View File

@@ -34,9 +34,12 @@ export interface MetricData {
gpuMem?: number;
gpuMemUtil?: number;
diskUtilPct?: number;
diskUsableSpace?: number;
npuUsage?: number[];
ipAddress?: string;
uptime?: number;
sentBitRate?: number;
recvBitRate?: number;
}
export enum NetworkConnectionType {
@@ -191,7 +194,7 @@ export interface BoardObservation {
locationInImageSpace: CvPoint[];
reprojectionErrors: CvPoint[];
optimisedCameraToObject: Pose3d;
includeObservationInCalibration: boolean;
cornersUsed: boolean[];
snapshotName: string;
snapshotData: JsonImageMat;
}
@@ -202,9 +205,15 @@ export interface CameraCalibrationResult {
distCoeffs: JsonMatOfDouble;
observations: BoardObservation[];
calobjectWarp?: number[];
// We might have to omit observations for bandwidth, so backend will send us this
calobjectSize: { width: number; height: number };
calobjectSpacing: number;
lensModel: string;
// We have to omit observations for bandwidth, so backend will send us this from UICameraCalibrationCoefficients
numSnapshots: number;
meanErrors: number[];
numMissing: number[];
numOutliers: number[];
}
export enum ValidQuirks {
@@ -264,6 +273,8 @@ export interface UiCameraConfiguration {
minWhiteBalanceTemp: number;
maxWhiteBalanceTemp: number;
fpsLimit: number;
matchedCameraInfo: PVCameraInfo;
isConnected: boolean;
hasConnected: boolean;
@@ -315,7 +326,7 @@ export const PlaceholderCameraSettings: UiCameraConfiguration = reactive({
rows: 1,
cols: 1,
type: 1,
data: [1, 2, 3, 4, 5, 6, 7, 8, 9]
data: [600, 0, 1920 / 2, 0, 600, 1080 / 2, 0, 0, 1]
},
distCoeffs: {
rows: 1,
@@ -325,28 +336,72 @@ export const PlaceholderCameraSettings: UiCameraConfiguration = reactive({
},
observations: [
{
locationInImageSpace: [
{ x: 100, y: 100 },
{ x: 210, y: 100 },
{ x: 320, y: 101 }
locationInObjectSpace: [
{
x: 0,
y: 0,
z: 0
},
{
x: 0.02539999969303608,
y: 0,
z: 0
},
{
x: 0.05079999938607216,
y: 0,
z: 0
}
],
locationInImageSpace: [
{
x: 57.062007904052734,
y: 108.12601470947266
},
{
x: 108.72974395751953,
y: 108.0336685180664
},
{
x: 158.78118896484375,
y: 107.8104019165039
}
],
locationInObjectSpace: [{ x: 0, y: 0, z: 0 }],
optimisedCameraToObject: {
translation: { x: 1, y: 2, z: 3 },
rotation: { quaternion: { W: 1, X: 0, Y: 0, Z: 0 } }
translation: {
x: -0.28942385915178886,
y: -0.12895727420625547,
z: 0.5933086404370699
},
rotation: {
quaternion: {
W: 0.9890028788589879,
X: -0.0507354429843431,
Y: -0.13458187019694584,
Z: -0.034452004994036174
}
}
},
reprojectionErrors: [
{ x: 1, y: 1 },
{ x: 2, y: 1 },
{ x: 3, y: 1 }
],
includeObservationInCalibration: false,
cornersUsed: [true, true, false],
snapshotName: "img0.png",
snapshotData: { rows: 480, cols: 640, type: CvType.CV_8U, data: "" }
}
],
calobjectSize: {
width: 10,
height: 10
},
calobjectSpacing: 0.0254,
lensModel: "opencv8",
numSnapshots: 1,
meanErrors: [123.45]
meanErrors: [123.45],
numMissing: [0],
numOutliers: [1]
}
],
pipelineNicknames: ["Placeholder Pipeline"],
@@ -389,6 +444,7 @@ export const PlaceholderCameraSettings: UiCameraConfiguration = reactive({
PVCSICameraInfo: undefined,
PVUsbCameraInfo: undefined
},
fpsLimit: -1,
isConnected: true,
hasConnected: true,
mismatch: false

View File

@@ -67,6 +67,7 @@ export interface WebsocketCameraSettingsUpdate {
minWhiteBalanceTemp: number;
maxWhiteBalanceTemp: number;
matchedCameraInfo: PVCameraInfo;
fpsLimit: number;
isConnected: boolean;
hasConnected: boolean;
mismatch: boolean;

View File

@@ -77,6 +77,10 @@ const conflictingCameraShown = computed<boolean>(() => {
return useSettingsStore().general.conflictingCameras.length > 0;
});
const fpsLimitWarningShown = computed<boolean>(() => {
return Object.values(useCameraSettingsStore().cameras).some((c) => c.fpsLimit > 0);
});
const showCameraSetupDialog = ref(useCameraSettingsStore().needsCameraConfiguration);
</script>
@@ -106,6 +110,19 @@ const showCameraSetupDialog = ref(useCameraSettingsStore().needsCameraConfigurat
Conflicting hostname detected! Please change the hostname in the <a href="#/settings">Settings tab</a>!
</span>
</v-alert>
<v-alert
v-if="fpsLimitWarningShown"
class="mb-3"
color="error"
density="compact"
icon="mdi-alert-circle-outline"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'tonal'"
>
<span
>One or more cameras have an FPS limit set! This may cause performance issues. Check your logs for more
information.
</span>
</v-alert>
<v-alert
v-if="conflictingCameraShown"
class="mb-3"

View File

@@ -1,24 +1,21 @@
<script setup lang="ts">
import MetricsCard from "@/components/settings/MetricsCard.vue";
import DeviceControlCard from "@/components/settings/DeviceControlCard.vue";
import ObjectDetectionCard from "@/components/settings/ObjectDetectionCard.vue";
import GlobalSettingsCard from "@/components/settings/GlobalSettingsCard.vue";
import LightingControlCard from "@/components/settings/LEDControlCard.vue";
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
import ApriltagControlCard from "@/components/settings/ApriltagControlCard.vue";
import DeviceCard from "@/components/settings/DeviceCard.vue";
</script>
<template>
<div class="pa-3">
<MetricsCard />
<DeviceControlCard />
<DeviceCard />
<GlobalSettingsCard />
<ObjectDetectionCard v-if="useSettingsStore().general.supportedBackends.length > 0" />
<LightingControlCard v-if="useSettingsStore().lighting.supported" />
<Suspense>
<!-- Allows us to import three js when it's actually needed -->
<ApriltagControlCard />
<template #fallback> Loading... </template>
</Suspense>
</div>

View File

@@ -1,4 +1,4 @@
apply plugin: 'edu.wpi.first.WpilibTools'
apply plugin: 'org.photonvision.tools.WpilibTools'
import java.nio.file.Path
@@ -32,16 +32,17 @@ dependencies {
implementation 'org.zeroturnaround:zt-zip:1.14'
implementation "org.xerial:sqlite-jdbc:3.41.0.0"
implementation 'com.diozero:diozero-core:1.4.1'
implementation 'com.github.oshi:oshi-core:6.9.1'
// The JNI libraries use wpilibNatives, the java libraries use implementation
if (jniPlatform == "linuxarm64") {
wpilibNatives("org.photonvision:rknn_jni-jni:$rknnVersion:$wpilibNativeName") {
wpilibNatives("org.photonvision:rknn_jni-jni:$rknnVersion:$jniPlatform") {
transitive = false
}
wpilibNatives("org.photonvision:rubik_jni-jni:$rubikVersion:$wpilibNativeName") {
wpilibNatives("org.photonvision:rubik_jni-jni:$rubikVersion:$jniPlatform") {
transitive = false
}
wpilibNatives("org.photonvision:photon-libcamera-gl-driver-jni:$libcameraDriverVersion:$wpilibNativeName") {
wpilibNatives("org.photonvision:photon-libcamera-gl-driver-jni:$libcameraDriverVersion:$jniPlatform") {
transitive = false
}
}
@@ -58,14 +59,8 @@ dependencies {
implementation "org.photonvision:photon-mrcal-java:$mrcalVersion"
// Only include mrcal natives on platforms that we build for
if (!(jniPlatform in [
"osxx86-64",
"osxarm64"
])) {
wpilibNatives("org.photonvision:photon-mrcal-jni:$mrcalVersion:$wpilibNativeName") {
transitive = false
}
wpilibNatives("org.photonvision:photon-mrcal-jni:$mrcalVersion:$jniPlatform") {
transitive = false
}
testImplementation group: 'org.junit-pioneer' , name: 'junit-pioneer', version: '2.2.0'

View File

@@ -191,9 +191,10 @@ public class CameraConfiguration {
}
/**
* Remove a calibration from our list.
* Remove a calibration from our list. If found, the calibration will be "released". If not found,
* no-op.
*
* @param calibration The calibration to remove
* @param unrotatedImageSize The resolution to remove.
*/
public void removeCalibration(Size unrotatedImageSize) {
logger.info("deleting calibration " + unrotatedImageSize);

View File

@@ -39,7 +39,7 @@ import org.photonvision.vision.processes.VisionSource;
import org.zeroturnaround.zip.ZipUtil;
public class ConfigManager {
private static ConfigManager INSTANCE;
static ConfigManager INSTANCE;
public static final String HW_CFG_FNAME = "hardwareConfig.json";
public static final String HW_SET_FNAME = "hardwareSettings.json";

View File

@@ -41,17 +41,6 @@ public class HardwareConfig {
public final String setPWMFrequencyCommand;
public final String releaseGPIOCommand;
// Metrics
public final String cpuTempCommand;
public final String cpuMemoryCommand;
public final String cpuUtilCommand;
public final String cpuThrottleReasonCmd;
public final String cpuUptimeCommand;
public final String gpuMemoryCommand;
public final String ramUtilCommand;
public final String gpuMemUsageCommand;
public final String diskUsageCommand;
// Device stuff
public final String restartHardwareCommand;
public final double vendorFOV; // -1 for unmanaged
@@ -71,15 +60,6 @@ public class HardwareConfig {
String setPWMCommand,
String setPWMFrequencyCommand,
String releaseGPIOCommand,
String cpuTempCommand,
String cpuMemoryCommand,
String cpuUtilCommand,
String cpuThrottleReasonCmd,
String cpuUptimeCommand,
String gpuMemoryCommand,
String ramUtilCommand,
String gpuMemUsageCommand,
String diskUsageCommand,
String restartHardwareCommand,
double vendorFOV) {
this.deviceName = deviceName;
@@ -96,15 +76,6 @@ public class HardwareConfig {
this.setPWMCommand = setPWMCommand;
this.setPWMFrequencyCommand = setPWMFrequencyCommand;
this.releaseGPIOCommand = releaseGPIOCommand;
this.cpuTempCommand = cpuTempCommand;
this.cpuMemoryCommand = cpuMemoryCommand;
this.cpuUtilCommand = cpuUtilCommand;
this.cpuThrottleReasonCmd = cpuThrottleReasonCmd;
this.cpuUptimeCommand = cpuUptimeCommand;
this.gpuMemoryCommand = gpuMemoryCommand;
this.ramUtilCommand = ramUtilCommand;
this.gpuMemUsageCommand = gpuMemUsageCommand;
this.diskUsageCommand = diskUsageCommand;
this.restartHardwareCommand = restartHardwareCommand;
this.vendorFOV = vendorFOV;
}
@@ -124,15 +95,6 @@ public class HardwareConfig {
setPWMCommand = "";
setPWMFrequencyCommand = "";
releaseGPIOCommand = "";
cpuTempCommand = "";
cpuMemoryCommand = "";
cpuUtilCommand = "";
cpuThrottleReasonCmd = "";
cpuUptimeCommand = "";
gpuMemoryCommand = "";
ramUtilCommand = "";
gpuMemUsageCommand = "";
diskUsageCommand = "";
restartHardwareCommand = "";
vendorFOV = -1;
}
@@ -144,21 +106,6 @@ public class HardwareConfig {
return vendorFOV > 0;
}
/**
* @return True if any info command has been configured to be non-empty, false otherwise
*/
public final boolean hasCommandsConfigured() {
return cpuTempCommand != ""
|| cpuMemoryCommand != ""
|| cpuUtilCommand != ""
|| cpuThrottleReasonCmd != ""
|| cpuUptimeCommand != ""
|| gpuMemoryCommand != ""
|| ramUtilCommand != ""
|| gpuMemUsageCommand != ""
|| diskUsageCommand != "";
}
/**
* @return True if any gpio command has been configured to be non-empty, false otherwise
*/
@@ -200,24 +147,6 @@ public class HardwareConfig {
+ setPWMFrequencyCommand
+ ", releaseGPIOCommand="
+ releaseGPIOCommand
+ ", cpuTempCommand="
+ cpuTempCommand
+ ", cpuMemoryCommand="
+ cpuMemoryCommand
+ ", cpuUtilCommand="
+ cpuUtilCommand
+ ", cpuThrottleReasonCmd="
+ cpuThrottleReasonCmd
+ ", cpuUptimeCommand="
+ cpuUptimeCommand
+ ", gpuMemoryCommand="
+ gpuMemoryCommand
+ ", ramUtilCommand="
+ ramUtilCommand
+ ", gpuMemUsageCommand="
+ gpuMemUsageCommand
+ ", diskUsageCommand="
+ diskUsageCommand
+ ", restartHardwareCommand="
+ restartHardwareCommand
+ ", vendorFOV="

View File

@@ -218,7 +218,7 @@ class LegacyConfigProvider extends ConfigProvider {
hardwareSettings,
networkConfig,
atfl,
new NeuralNetworkPropertyManager(),
new NeuralNetworkModelsSettings(),
cameraConfigurations);
}

View File

@@ -34,7 +34,7 @@ import java.util.Optional;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.stream.Stream;
import org.photonvision.common.configuration.NeuralNetworkPropertyManager.ModelProperties;
import org.photonvision.common.configuration.NeuralNetworkModelsSettings.ModelProperties;
import org.photonvision.common.hardware.Platform;
import org.photonvision.common.logging.LogGroup;
import org.photonvision.common.logging.Logger;
@@ -50,20 +50,20 @@ import org.photonvision.vision.objects.RubikModel;
* extracted to the filesystem, it will not be extracted again.
*
* <p>Each model must have a corresponding {@link ModelProperties} entry in {@link
* NeuralNetworkPropertyManager}.
* NeuralNetworkModelsSettings}.
*/
public class NeuralNetworkModelManager {
/** Singleton instance of the NeuralNetworkModelManager */
private static NeuralNetworkModelManager INSTANCE;
private final List<Family> supportedBackends = new ArrayList<>();
final List<Family> supportedBackends = new ArrayList<>();
/**
* This function stores the properties of the shipped object detection models. It is stored as a
* function so that it can be dynamic, to adjust for the models directory.
*/
private NeuralNetworkPropertyManager getShippedProperties(File modelsDirectory) {
NeuralNetworkPropertyManager nnProps = new NeuralNetworkPropertyManager();
private NeuralNetworkModelsSettings getShippedProperties(File modelsDirectory) {
NeuralNetworkModelsSettings nnProps = new NeuralNetworkModelsSettings();
LinkedList<String> cocoLabels =
new LinkedList<String>(
@@ -146,19 +146,9 @@ public class NeuralNetworkModelManager {
"vase",
"scissors",
"teddy bear",
"hair drier",
"hair drier", // Typo in official COCO documentation
"toothbrush"));
nnProps.addModelProperties(
new ModelProperties(
Path.of(modelsDirectory.getAbsolutePath(), "algaeV1-640-640-yolov8n.rknn"),
"Algae v8n",
new LinkedList<String>(List.of("Algae")),
640,
480,
Family.RKNN,
Version.YOLOV8));
nnProps.addModelProperties(
new ModelProperties(
Path.of(modelsDirectory.getAbsolutePath(), "yolov8nCOCO.rknn"),
@@ -171,13 +161,13 @@ public class NeuralNetworkModelManager {
nnProps.addModelProperties(
new ModelProperties(
Path.of(modelsDirectory.getAbsolutePath(), "algae-coral-yolov8s.tflite"),
"Algae Coral v8s",
new LinkedList<String>(List.of("Algae", "Coral")),
Path.of(modelsDirectory.getAbsolutePath(), "fuelV1-yolo11n.rknn"),
"Fuel v11n",
new LinkedList<String>(List.of("Fuel")),
640,
640,
Family.RUBIK,
Version.YOLOV8));
Family.RKNN,
Version.YOLOV11));
nnProps.addModelProperties(
new ModelProperties(
@@ -189,6 +179,16 @@ public class NeuralNetworkModelManager {
Family.RUBIK,
Version.YOLOV8));
nnProps.addModelProperties(
new ModelProperties(
Path.of(modelsDirectory.getAbsolutePath(), "fuelV1-yolo11n.tflite"),
"Fuel v11n",
new LinkedList<String>(List.of("Fuel")),
640,
640,
Family.RUBIK,
Version.YOLOV11));
return nnProps;
}
@@ -272,7 +272,7 @@ public class NeuralNetworkModelManager {
*
* <p>The first model in the list is the default model.
*/
private Map<Family, ArrayList<Model>> models;
Map<Family, ArrayList<Model>> models;
/**
* Retrieves the model with the specified name, assuming it is available under a supported
@@ -321,13 +321,27 @@ public class NeuralNetworkModelManager {
ConfigManager.getInstance().getConfig().neuralNetworkPropertyManager().getModel(path);
if (properties == null) {
logger.error(
logger.warn(
"Model properties are null. This could mean the config for model "
+ path
+ " was unable to be found in the database.");
return;
+ " was unable to be found in the database. Trying legacy...");
try {
properties = ModelProperties.createFromFilename(path.getFileName().toString());
// At this point this property is not serialized or known to our configuration. add to
// NeuralNetworkModelsSettings
ConfigManager.getInstance()
.getConfig()
.neuralNetworkPropertyManager()
.addModelProperties(properties);
} catch (IllegalArgumentException | IOException e) {
logger.error("Failed to translate legacy model filename to properties: " + path, e);
return;
}
}
logger.debug(properties.toString());
if (!supportedBackends.contains(properties.family())) {
logger.warn(
"Model "
@@ -412,13 +426,11 @@ public class NeuralNetworkModelManager {
File modelsDirectory = ConfigManager.getInstance().getModelsDirectory();
// Filter shippedProprties by supportedBackends
NeuralNetworkPropertyManager supportedProperties = new NeuralNetworkPropertyManager();
NeuralNetworkModelsSettings supportedProperties = new NeuralNetworkModelsSettings();
for (ModelProperties model : getShippedProperties(modelsDirectory).getModels()) {
if (supportedBackends.contains(model.family())) {
supportedProperties.addModelProperties(model);
} else {
logger.warn(
"Skipping model " + model.nickname() + " as it is not supported on this platform.");
logger.debug("Added shipped model: " + model.modelPath().getFileName().toString());
}
}

View File

@@ -0,0 +1,277 @@
/*
* 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.common.configuration;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.photonvision.common.configuration.NeuralNetworkModelManager.Family;
import org.photonvision.common.configuration.NeuralNetworkModelManager.Version;
public class NeuralNetworkModelsSettings {
/*
* The properties of the model. This is used to determine which model to load.
* The only families currently supported are RKNN and Rubik (custom .tflite)
*/
public record ModelProperties(
@JsonProperty("modelPath") Path modelPath,
@JsonProperty("nickname") String nickname,
@JsonProperty("labels") List<String> labels,
@JsonProperty("resolutionWidth") int resolutionWidth,
@JsonProperty("resolutionHeight") int resolutionHeight,
@JsonProperty("family") Family family,
@JsonProperty("version") Version version) {
@JsonCreator
public ModelProperties {}
ModelProperties(ModelProperties other) {
this(
other.modelPath,
other.nickname,
other.labels, // note this does not clone the underlying list
other.resolutionWidth,
other.resolutionHeight,
other.family,
other.version);
}
// In v2025.3.1, this was single string for the model path. but the first argument
// is now nickname
public ModelProperties(@JsonProperty("nickname") String filename)
throws IllegalArgumentException, IOException {
this(createFromFilename(filename));
}
// ============= Migration code from v2025.3.1 ===========
private static Pattern modelPattern =
Pattern.compile("^([a-zA-Z0-9._]+)-(\\d+)-(\\d+)-(yolov(?:5|8|11)[nsmlx]*)\\.rknn$");
static ModelProperties createFromFilename(String modelFileName)
throws IllegalArgumentException, IOException {
// Used to point to default models directory
var model =
ConfigManager.getInstance().getModelsDirectory().toPath().resolve(modelFileName).toFile();
// Get the model extension and check if it is supported
String modelExtension = model.getName().substring(model.getName().lastIndexOf('.'));
if (!modelExtension.equals(".rknn")) {
throw new IllegalArgumentException("Model " + modelFileName + " is not a supported format");
}
var backend =
Arrays.stream(NeuralNetworkModelManager.Family.values())
.filter(b -> b.extension().equals(modelExtension))
.findFirst();
if (!backend.isPresent()) {
throw new IllegalArgumentException("Model " + modelFileName + " cannot find backend");
}
String labelFile = model.getAbsolutePath().replace(backend.get().extension(), "-labels.txt");
List<String> labels = Files.readAllLines(Paths.get(labelFile));
String[] parts = parseRKNNName(modelFileName);
var version = getModelVersion(parts[3]);
int width = Integer.parseInt(parts[1]);
int height = Integer.parseInt(parts[2]);
return new ModelProperties(
model.toPath(),
model.getName(),
labels,
// all files used to be 640x640
width,
height,
Family.RKNN,
version);
}
/**
* Determines the model version based on the model's filename.
*
* <p>"yolov5" -> "YOLO_V5"
*
* <p>"yolov8" -> "YOLO_V8"
*
* <p>"yolov11" -> "YOLO_V11"
*
* @param modelName The model's filename
* @return The model version
*/
private static Version getModelVersion(String modelName) throws IllegalArgumentException {
if (modelName.contains("yolov5")) {
return Version.YOLOV5;
} else if (modelName.contains("yolov8")) {
return Version.YOLOV8;
} else if (modelName.contains("yolov11")) {
return Version.YOLOV11;
} else {
throw new IllegalArgumentException("Unknown model version for model " + modelName);
}
}
/**
* 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)
};
}
}
// The path to the model is used as the key in the map because it is unique to
// the model, and should not change
@JsonProperty("modelPathToProperties")
private HashMap<Path, ModelProperties> modelPathToProperties =
new HashMap<Path, ModelProperties>();
/**
* Constructor for the NeuralNetworkProperties class.
*
* <p>This object holds a LinkedList of {@link ModelProperties} objects
*/
public NeuralNetworkModelsSettings() {}
/**
* Constructor for the NeuralNetworkProperties class.
*
* <p>This object holds a LinkedList of {@link ModelProperties} objects.
*
* @param modelPropertiesList When the class is constructed, it will hold the provided list
*/
public NeuralNetworkModelsSettings(HashMap<Path, ModelProperties> modelPropertiesList) {}
@Override
public String toString() {
String toReturn = "";
toReturn += "NeuralNetworkProperties [";
toReturn += modelPathToProperties.toString() + "]";
return toReturn;
}
/**
* Add a model to the list of models.
*
* @param modelProperties
*/
public void addModelProperties(ModelProperties modelProperties) {
modelPathToProperties.put(modelProperties.modelPath, modelProperties);
}
/**
* Add two Neural Network Properties together.
*
* <p>Any properties that are the same will be overwritten by the second
*
* @param nnProps
* @return itself, so it can be chained and used fluently
*/
public NeuralNetworkModelsSettings sum(NeuralNetworkModelsSettings nnProps) {
modelPathToProperties.putAll(nnProps.modelPathToProperties);
return this;
}
/**
* Remove a model from the list of models.
*
* @param modelPath
* @return True if the model was removed, false if it was not found
*/
public boolean removeModel(Path modelPath) {
return modelPathToProperties.remove(modelPath) != null;
}
/**
* Get the model properties for a given model path.
*
* @param modelPath
* @return {@link ModelProperties} object
*/
public ModelProperties getModel(Path modelPath) {
return modelPathToProperties.get(modelPath);
}
/**
* Get all models
*
* @return A list of all models
*/
@JsonIgnore
public ModelProperties[] getModels() {
return modelPathToProperties.values().toArray(new ModelProperties[0]);
}
/**
* Change the nickname of a {@link ModelProperties} object.
*
* @param modelPath
* @param newName
* @return True if the model was found and renamed, false if it was not found
*/
public boolean renameModel(Path modelPath, String newName) {
ModelProperties temp = modelPathToProperties.get(modelPath);
if (temp != null) {
modelPathToProperties.remove(modelPath);
modelPathToProperties.put(
modelPath,
new ModelProperties(
temp.modelPath,
newName,
temp.labels,
temp.resolutionWidth,
temp.resolutionHeight,
temp.family,
temp.version));
return true;
}
return false;
}
public boolean clear() {
modelPathToProperties.clear();
return true;
}
}

View File

@@ -1,164 +0,0 @@
/*
* 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.common.configuration;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.LinkedList;
import org.photonvision.common.configuration.NeuralNetworkModelManager.Family;
import org.photonvision.common.configuration.NeuralNetworkModelManager.Version;
public class NeuralNetworkPropertyManager {
/*
* The properties of the model. This is used to determine which model to load.
* The only families currently supported are RKNN and Rubik (custom .tflite)
*/
public record ModelProperties(
@JsonProperty("modelPath") Path modelPath,
@JsonProperty("nickname") String nickname,
@JsonProperty("labels") LinkedList<String> labels,
@JsonProperty("resolutionWidth") int resolutionWidth,
@JsonProperty("resolutionHeight") int resolutionHeight,
@JsonProperty("family") Family family,
@JsonProperty("version") Version version) {
@JsonCreator
public ModelProperties {
// Record constructor is automatically annotated with @JsonCreator
}
}
// The path to the model is used as the key in the map because it is unique to
// the model, and should not change
@JsonProperty("modelPathToProperties")
private HashMap<Path, ModelProperties> modelPathToProperties =
new HashMap<Path, ModelProperties>();
/**
* Constructor for the NeuralNetworkProperties class.
*
* <p>This object holds a LinkedList of {@link ModelProperties} objects
*/
public NeuralNetworkPropertyManager() {}
/**
* Constructor for the NeuralNetworkProperties class.
*
* <p>This object holds a LinkedList of {@link ModelProperties} objects.
*
* @param modelPropertiesList When the class is constructed, it will hold the provided list
*/
public NeuralNetworkPropertyManager(HashMap<Path, ModelProperties> modelPropertiesList) {}
@Override
public String toString() {
String toReturn = "";
toReturn += "NeuralNetworkProperties [";
toReturn += modelPathToProperties.toString() + "]";
return toReturn;
}
/**
* Add a model to the list of models.
*
* @param modelProperties
*/
public void addModelProperties(ModelProperties modelProperties) {
modelPathToProperties.put(modelProperties.modelPath, modelProperties);
}
/**
* Add two Neural Network Properties together.
*
* <p>Any properties that are the same will be overwritten by the second
*
* @param nnProps
* @return itself, so it can be chained and used fluently
*/
public NeuralNetworkPropertyManager sum(NeuralNetworkPropertyManager nnProps) {
modelPathToProperties.putAll(nnProps.modelPathToProperties);
return this;
}
/**
* Remove a model from the list of models.
*
* @param modelPath
* @return True if the model was removed, false if it was not found
*/
public boolean removeModel(Path modelPath) {
return modelPathToProperties.remove(modelPath) != null;
}
/**
* Get the model properties for a given model path.
*
* @param modelPath
* @return {@link ModelProperties} object
*/
public ModelProperties getModel(Path modelPath) {
return modelPathToProperties.get(modelPath);
}
/**
* Get all models
*
* @return A list of all models
*/
@JsonIgnore
public ModelProperties[] getModels() {
return modelPathToProperties.values().toArray(new ModelProperties[0]);
}
/**
* Change the nickname of a {@link ModelProperties} object.
*
* @param modelPath
* @param newName
* @return True if the model was found and renamed, false if it was not found
*/
public boolean renameModel(Path modelPath, String newName) {
ModelProperties temp = modelPathToProperties.get(modelPath);
if (temp != null) {
modelPathToProperties.remove(modelPath);
modelPathToProperties.put(
modelPath,
new ModelProperties(
temp.modelPath,
newName,
temp.labels,
temp.resolutionWidth,
temp.resolutionHeight,
temp.family,
temp.version));
return true;
}
return false;
}
public boolean clear() {
modelPathToProperties.clear();
return true;
}
}

View File

@@ -28,7 +28,7 @@ public class PhotonConfiguration {
private final HardwareSettings hardwareSettings;
private NetworkConfig networkConfig;
private AprilTagFieldLayout atfl;
private NeuralNetworkPropertyManager neuralNetworkProperties;
private NeuralNetworkModelsSettings neuralNetworkProperties;
private HashMap<String, CameraConfiguration> cameraConfigurations;
public PhotonConfiguration(
@@ -36,7 +36,7 @@ public class PhotonConfiguration {
HardwareSettings hardwareSettings,
NetworkConfig networkConfig,
AprilTagFieldLayout atfl,
NeuralNetworkPropertyManager neuralNetworkProperties) {
NeuralNetworkModelsSettings neuralNetworkProperties) {
this(
hardwareConfig,
hardwareSettings,
@@ -51,7 +51,7 @@ public class PhotonConfiguration {
HardwareSettings hardwareSettings,
NetworkConfig networkConfig,
AprilTagFieldLayout atfl,
NeuralNetworkPropertyManager neuralNetworkProperties,
NeuralNetworkModelsSettings neuralNetworkProperties,
HashMap<String, CameraConfiguration> cameraConfigurations) {
this.hardwareConfig = hardwareConfig;
this.hardwareSettings = hardwareSettings;
@@ -67,7 +67,7 @@ public class PhotonConfiguration {
new HardwareSettings(),
new NetworkConfig(),
new AprilTagFieldLayout(List.of(), 0, 0),
new NeuralNetworkPropertyManager());
new NeuralNetworkModelsSettings());
}
public HardwareConfig getHardwareConfig() {
@@ -86,7 +86,7 @@ public class PhotonConfiguration {
return atfl;
}
public NeuralNetworkPropertyManager neuralNetworkPropertyManager() {
public NeuralNetworkModelsSettings neuralNetworkPropertyManager() {
return neuralNetworkProperties;
}
@@ -98,7 +98,7 @@ public class PhotonConfiguration {
this.networkConfig = networkConfig;
}
public void setNeuralNetworkProperties(NeuralNetworkPropertyManager neuralNetworkProperties) {
public void setNeuralNetworkProperties(NeuralNetworkModelsSettings neuralNetworkProperties) {
this.neuralNetworkProperties = neuralNetworkProperties;
}
@@ -132,6 +132,16 @@ public class PhotonConfiguration {
@Override
public String toString() {
StringBuilder cameraConfigurationsString = new StringBuilder();
cameraConfigurations.forEach(
(key, value) -> {
cameraConfigurationsString
.append("\n ")
.append(key)
.append(" -> ")
.append(value.toString());
});
return "PhotonConfiguration [\n hardwareConfig="
+ hardwareConfig
+ "\n hardwareSettings="
@@ -142,8 +152,8 @@ public class PhotonConfiguration {
+ atfl
+ "\n neuralNetworkProperties="
+ neuralNetworkProperties
+ "\n cameraConfigurations="
+ cameraConfigurations
+ "\n]";
+ "\n cameraConfigurations={"
+ cameraConfigurationsString
+ "}\n]";
}
}

View File

@@ -30,6 +30,7 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import org.photonvision.common.configuration.CameraConfiguration.LegacyCameraConfigStruct;
import org.photonvision.common.configuration.DatabaseSchema.Columns;
@@ -253,6 +254,45 @@ public class SqlConfigProvider extends ConfigProvider {
return true;
}
private <T> T loadConfigOrDefault(
Connection conn, String key, Class<T> ref, Supplier<T> factory) {
String configString = getOneConfigFile(conn, key);
T configObj;
if (!configString.isBlank()) {
try {
configObj = JacksonUtils.deserialize(configString, ref);
logger.info("Loaded " + ref.getSimpleName() + " from database");
return configObj;
} catch (IOException e) {
logger.error("Could not deserialize " + ref.getSimpleName() + " from database!", e);
}
} else {
logger.debug("No " + ref.getSimpleName() + " in database");
}
// either the config entry is empty or Jackson threw an exception
try {
configObj = factory.get();
logger.info("Loaded default " + ref.getSimpleName());
return configObj;
} catch (Exception e) {
logger.error("Failed to construct a default instance of " + ref.getSimpleName(), e);
}
return null;
}
private AprilTagFieldLayout atflDefault() {
AprilTagFieldLayout atfl;
try {
atfl = AprilTagFieldLayout.loadField(AprilTagFields.kDefaultField);
logger.info("Loaded " + AprilTagFields.kDefaultField.toString() + " field");
} catch (UncheckedIOException e) {
logger.error("Error loading WPILib field", e);
logger.info("Creating an empty field");
atfl = new AprilTagFieldLayout(List.of(), 1, 1);
}
return atfl;
}
@Override
public void load() {
logger.debug("Loading config...");
@@ -260,68 +300,24 @@ public class SqlConfigProvider extends ConfigProvider {
if (conn == null) return;
synchronized (m_mutex) {
HardwareConfig hardwareConfig;
HardwareSettings hardwareSettings;
NetworkConfig networkConfig;
AprilTagFieldLayout atfl;
NeuralNetworkPropertyManager nnProps;
try {
hardwareConfig =
JacksonUtils.deserialize(
getOneConfigFile(conn, GlobalKeys.HARDWARE_CONFIG), HardwareConfig.class);
} catch (IOException e) {
logger.error("Could not deserialize hardware config! Loading defaults", e);
hardwareConfig = new HardwareConfig();
}
try {
hardwareSettings =
JacksonUtils.deserialize(
getOneConfigFile(conn, GlobalKeys.HARDWARE_SETTINGS), HardwareSettings.class);
} catch (IOException e) {
logger.error("Could not deserialize hardware settings! Loading defaults", e);
hardwareSettings = new HardwareSettings();
}
try {
networkConfig =
JacksonUtils.deserialize(
getOneConfigFile(conn, GlobalKeys.NETWORK_CONFIG), NetworkConfig.class);
} catch (IOException e) {
logger.error("Could not deserialize network config! Loading defaults", e);
networkConfig = new NetworkConfig();
}
try {
atfl =
JacksonUtils.deserialize(
getOneConfigFile(conn, GlobalKeys.ATFL_CONFIG_FILE), AprilTagFieldLayout.class);
} catch (IOException e) {
logger.error("Could not deserialize apriltag layout! Loading defaults", e);
try {
atfl = AprilTagFieldLayout.loadField(AprilTagFields.kDefaultField);
} catch (UncheckedIOException e2) {
logger.error("Error loading WPILib field", e);
atfl = null;
}
if (atfl == null) {
// what do we even do here lmao -- wpilib should always work
logger.error("Field layout is *still* null??????");
atfl = new AprilTagFieldLayout(List.of(), 1, 1);
}
}
try {
nnProps =
JacksonUtils.deserialize(
getOneConfigFile(conn, GlobalKeys.NEURAL_NETWORK_PROPERTIES),
NeuralNetworkPropertyManager.class);
} catch (IOException e) {
logger.error("Could not deserialize neural network properties! Loading defaults", e);
nnProps = new NeuralNetworkPropertyManager();
}
var hardwareConfig =
loadConfigOrDefault(
conn, GlobalKeys.HARDWARE_CONFIG, HardwareConfig.class, HardwareConfig::new);
var hardwareSettings =
loadConfigOrDefault(
conn, GlobalKeys.HARDWARE_SETTINGS, HardwareSettings.class, HardwareSettings::new);
var networkConfig =
loadConfigOrDefault(
conn, GlobalKeys.NETWORK_CONFIG, NetworkConfig.class, NetworkConfig::new);
var nnProps =
loadConfigOrDefault(
conn,
GlobalKeys.NEURAL_NETWORK_PROPERTIES,
NeuralNetworkModelsSettings.class,
NeuralNetworkModelsSettings::new);
var atfl =
loadConfigOrDefault(
conn, GlobalKeys.ATFL_CONFIG_FILE, AprilTagFieldLayout.class, this::atflDefault);
var cams = loadCameraConfigs(conn);
try {
@@ -616,52 +612,65 @@ public class SqlConfigProvider extends ConfigProvider {
// Iterate over every row/"camera" in the table
while (result.next()) {
List<String> dummyList = new ArrayList<>();
String uniqueName = "";
try {
List<String> dummyList = new ArrayList<>();
var uniqueName = result.getString(Columns.CAM_UNIQUE_NAME);
uniqueName = result.getString(Columns.CAM_UNIQUE_NAME);
// A horrifying hack to keep backward compat with otherpaths
// We -really- need to delete this -stupid- otherpaths column. I hate it.
var configStr = result.getString(Columns.CAM_CONFIG_JSON);
CameraConfiguration config = JacksonUtils.deserialize(configStr, CameraConfiguration.class);
// A horrifying hack to keep backward compat with otherpaths
// We -really- need to delete this -stupid- otherpaths column. I hate it.
var configStr = result.getString(Columns.CAM_CONFIG_JSON);
CameraConfiguration config =
JacksonUtils.deserialize(configStr, CameraConfiguration.class);
if (config.matchedCameraInfo == null) {
logger.info("Legacy CameraConfiguration detected - upgrading");
if (config.matchedCameraInfo == null) {
logger.info("Legacy CameraConfiguration detected - upgrading");
// manually create the matchedCameraInfo ourselves. Need to upgrade:
// baseName, path, otherPaths, cameraType, usbvid/pid -> matchedCameraInfo
config.matchedCameraInfo =
JacksonUtils.deserialize(configStr, LegacyCameraConfigStruct.class).matchedCameraInfo;
// manually create the matchedCameraInfo ourselves. Need to upgrade:
// baseName, path, otherPaths, cameraType, usbvid/pid -> matchedCameraInfo
config.matchedCameraInfo =
JacksonUtils.deserialize(configStr, LegacyCameraConfigStruct.class)
.matchedCameraInfo;
// Except that otherPaths used to be its own column. so hack that in here as well
var otherPaths =
// Except that otherPaths used to be its own column. so hack that in here as well
var otherPaths =
JacksonUtils.deserialize(
result.getString(Columns.CAM_OTHERPATHS_JSON), String[].class);
if (config.matchedCameraInfo instanceof UsbCameraInfo usbInfo) {
usbInfo.otherPaths = otherPaths;
}
}
var driverMode =
JacksonUtils.deserialize(
result.getString(Columns.CAM_OTHERPATHS_JSON), String[].class);
if (config.matchedCameraInfo instanceof UsbCameraInfo usbInfo) {
usbInfo.otherPaths = otherPaths;
result.getString(Columns.CAM_DRIVERMODE_JSON), DriverModePipelineSettings.class);
List<?> pipelineSettings =
JacksonUtils.deserialize(
result.getString(Columns.CAM_PIPELINE_JSONS), dummyList.getClass());
List<CVPipelineSettings> loadedSettings = new ArrayList<>();
for (var setting : pipelineSettings) {
if (setting instanceof String str) {
try {
loadedSettings.add(JacksonUtils.deserialize(str, CVPipelineSettings.class));
} catch (IOException e) {
logger.error(
"Could not deserialize pipeline setting for camera " + config.nickname, e);
}
}
}
config.pipelineSettings = loadedSettings;
config.driveModeSettings = driverMode;
loadedConfigurations.put(uniqueName, config);
} catch (IOException e) {
logger.error(
"Could not deserialize camera configuration " + uniqueName + " from database!", e);
}
var driverMode =
JacksonUtils.deserialize(
result.getString(Columns.CAM_DRIVERMODE_JSON), DriverModePipelineSettings.class);
List<?> pipelineSettings =
JacksonUtils.deserialize(
result.getString(Columns.CAM_PIPELINE_JSONS), dummyList.getClass());
List<CVPipelineSettings> loadedSettings = new ArrayList<>();
for (var setting : pipelineSettings) {
if (setting instanceof String str) {
loadedSettings.add(JacksonUtils.deserialize(str, CVPipelineSettings.class));
}
}
config.pipelineSettings = loadedSettings;
config.driveModeSettings = driverMode;
loadedConfigurations.put(uniqueName, config);
}
} catch (SQLException | IOException e) {
logger.error("Err loading cameras: ", e);
} catch (SQLException e) {
logger.error("Err querying database to load cameras: ", e);
} finally {
try {
if (query != null) query.close();

View File

@@ -51,16 +51,24 @@ public class NTDataPublisher implements CVPipelineResultConsumer {
private final BooleanSupplier driverModeSupplier;
private final Consumer<Boolean> driverModeConsumer;
NTDataChangeListener fpsLimitListener;
private final Consumer<Integer> fpsLimitConsumer;
private final Supplier<Integer> fpsLimitSupplier;
public NTDataPublisher(
String cameraNickname,
Supplier<Integer> pipelineIndexSupplier,
Consumer<Integer> pipelineIndexConsumer,
BooleanSupplier driverModeSupplier,
Consumer<Boolean> driverModeConsumer) {
Consumer<Boolean> driverModeConsumer,
Supplier<Integer> fpsLimitSupplier,
Consumer<Integer> fpsLimitConsumer) {
this.pipelineIndexSupplier = pipelineIndexSupplier;
this.pipelineIndexConsumer = pipelineIndexConsumer;
this.driverModeSupplier = driverModeSupplier;
this.driverModeConsumer = driverModeConsumer;
this.fpsLimitSupplier = fpsLimitSupplier;
this.fpsLimitConsumer = fpsLimitConsumer;
updateCameraNickname(cameraNickname);
updateEntries();
@@ -103,6 +111,19 @@ public class NTDataPublisher implements CVPipelineResultConsumer {
logger.debug("Set driver mode to " + newDriverMode);
}
private void onFPSLimitChange(NetworkTableEvent entryNotification) {
var newFPSLimit = (int) entryNotification.valueData.value.getInteger();
var originalFPSLimit = fpsLimitSupplier.get();
if (newFPSLimit == originalFPSLimit) {
logger.debug("FPS limit is already " + newFPSLimit);
return;
}
fpsLimitConsumer.accept(newFPSLimit);
logger.debug("Set FPS limit to " + newFPSLimit);
}
private void removeEntries() {
if (pipelineIndexListener != null) pipelineIndexListener.remove();
if (driverModeListener != null) driverModeListener.remove();
@@ -112,6 +133,7 @@ public class NTDataPublisher implements CVPipelineResultConsumer {
private void updateEntries() {
if (pipelineIndexListener != null) pipelineIndexListener.remove();
if (driverModeListener != null) driverModeListener.remove();
if (fpsLimitListener != null) fpsLimitListener.remove();
ts.updateEntries();
@@ -122,6 +144,10 @@ public class NTDataPublisher implements CVPipelineResultConsumer {
driverModeListener =
new NTDataChangeListener(
ts.subTable.getInstance(), ts.driverModeSubscriber, this::onDriverModeChange);
fpsLimitListener =
new NTDataChangeListener(
ts.subTable.getInstance(), ts.fpsLimitSubscriber, this::onFPSLimitChange);
}
public void updateCameraNickname(String newCameraNickname) {
@@ -170,6 +196,7 @@ public class NTDataPublisher implements CVPipelineResultConsumer {
ts.pipelineIndexPublisher.set(pipelineIndexSupplier.get());
ts.driverModePublisher.set(driverModeSupplier.getAsBoolean());
ts.fpsLimitPublisher.set(fpsLimitSupplier.get());
ts.latencyMillisEntry.set(acceptedResult.getLatencyMillis());
ts.fpsEntry.set(acceptedResult.fps);
ts.hasTargetEntry.set(acceptedResult.hasTargets());

View File

@@ -45,8 +45,6 @@ import org.photonvision.common.logging.LogGroup;
import org.photonvision.common.logging.LogLevel;
import org.photonvision.common.logging.Logger;
import org.photonvision.common.networking.NetworkUtils;
import org.photonvision.common.scripting.ScriptEventType;
import org.photonvision.common.scripting.ScriptManager;
import org.photonvision.common.util.TimedTaskManager;
import org.photonvision.common.util.file.JacksonUtils;
@@ -183,7 +181,6 @@ public class NetworkTablesManager {
logger.info(msg);
HardwareManager.getInstance().setNTConnected(true);
ScriptManager.queueEvent(ScriptEventType.kNTConnected);
getInstance().broadcastVersion();
getInstance().broadcastConnectedStatus();

View File

@@ -54,6 +54,8 @@ public class UICameraConfiguration {
public PVCameraInfo matchedCameraInfo;
public boolean mismatch;
public int fpsLimit;
// Status for if the underlying device is present and such
public boolean isConnected;
public boolean hasConnected;

View File

@@ -18,14 +18,14 @@
package org.photonvision.common.dataflow.websocket;
import java.util.List;
import org.photonvision.common.configuration.NeuralNetworkPropertyManager;
import org.photonvision.common.configuration.NeuralNetworkModelsSettings;
public class UIGeneralSettings {
public UIGeneralSettings(
String version,
String gpuAcceleration,
boolean mrCalWorking,
NeuralNetworkPropertyManager.ModelProperties[] availableModels,
NeuralNetworkModelsSettings.ModelProperties[] availableModels,
List<String> supportedBackends,
String hardwareModel,
String hardwarePlatform,
@@ -45,7 +45,7 @@ public class UIGeneralSettings {
public String version;
public String gpuAcceleration;
public boolean mrCalWorking;
public NeuralNetworkPropertyManager.ModelProperties[] availableModels;
public NeuralNetworkModelsSettings.ModelProperties[] availableModels;
public List<String> supportedBackends;
public String hardwareModel;
public String hardwarePlatform;

View File

@@ -35,11 +35,9 @@ import org.photonvision.common.dataflow.networktables.NTDataChangeListener;
import org.photonvision.common.dataflow.networktables.NetworkTablesManager;
import org.photonvision.common.hardware.gpio.CustomAdapter;
import org.photonvision.common.hardware.gpio.CustomDeviceFactory;
import org.photonvision.common.hardware.metrics.MetricsManager;
import org.photonvision.common.logging.LogGroup;
import org.photonvision.common.logging.Logger;
import org.photonvision.common.util.ShellExec;
import org.photonvision.common.util.TimedTaskManager;
public class HardwareManager {
private static HardwareManager instance;
@@ -50,8 +48,6 @@ public class HardwareManager {
private final HardwareConfig hardwareConfig;
private final HardwareSettings hardwareSettings;
private final MetricsManager metricsManager;
@SuppressWarnings({"FieldCanBeLocal", "unused"})
private final StatusLED statusLED;
@@ -77,12 +73,6 @@ public class HardwareManager {
this.hardwareConfig = hardwareConfig;
this.hardwareSettings = hardwareSettings;
this.metricsManager = new MetricsManager();
this.metricsManager.setConfig(hardwareConfig);
TimedTaskManager.getInstance()
.addTask("Metrics Publisher", this.metricsManager::publishMetrics, 5000);
ledModeRequest =
NetworkTablesManager.getInstance()
.kRootTable
@@ -259,8 +249,4 @@ public class HardwareManager {
}
statusLED.setStatus(status);
}
public void publishMetrics() {
metricsManager.publishMetrics();
}
}

View File

@@ -43,6 +43,7 @@ public class VisionLED implements AutoCloseable {
private VisionLEDMode currentLedMode = VisionLEDMode.kDefault;
private BooleanSupplier pipelineModeSupplier;
private boolean currentOutputState = false;
private float mappedBrightness = 0.0f;
@@ -85,10 +86,15 @@ public class VisionLED implements AutoCloseable {
public void setBrightness(int percentage) {
mappedBrightness =
(float) (MathUtils.map(percentage, 0.0, 100.0, brightnessMin, brightnessMax) / 100.0);
setInternal(currentLedMode, false);
if (currentOutputState) {
for (PwmLed led : dimmableVisionLEDs) {
led.setValue(mappedBrightness);
}
}
}
public void blink(int pulseLengthMillis, int blinkCount) {
TimedTaskManager.getInstance().cancelTask(blinkTaskID);
blinkImpl(pulseLengthMillis, blinkCount);
int blinkDuration = pulseLengthMillis * blinkCount * 2;
TimedTaskManager.getInstance()
@@ -96,19 +102,13 @@ public class VisionLED implements AutoCloseable {
}
private void blinkImpl(int pulseLengthMillis, int blinkCount) {
TimedTaskManager.getInstance().cancelTask(blinkTaskID);
AtomicInteger blinks = new AtomicInteger();
TimedTaskManager.getInstance()
.addTask(
blinkTaskID,
() -> {
for (LED led : visionLEDs) {
led.toggle();
}
for (PwmLed led : dimmableVisionLEDs) {
led.setValue(mappedBrightness - led.getValue());
}
if (blinks.incrementAndGet() >= blinkCount * 2) {
setStateImpl(!currentOutputState);
if (blinkCount >= 0 && blinks.incrementAndGet() >= blinkCount * 2) {
TimedTaskManager.getInstance().cancelTask(blinkTaskID);
}
},
@@ -116,12 +116,16 @@ public class VisionLED implements AutoCloseable {
}
private void setStateImpl(boolean state) {
TimedTaskManager.getInstance().cancelTask(blinkTaskID);
currentOutputState = state;
for (LED led : visionLEDs) {
led.setOn(state);
}
for (PwmLed led : dimmableVisionLEDs) {
led.setValue(mappedBrightness);
if (state) {
led.setValue(mappedBrightness);
} else {
led.off();
}
}
}
@@ -162,6 +166,7 @@ public class VisionLED implements AutoCloseable {
var lastLedMode = currentLedMode;
if (fromNT || currentLedMode == VisionLEDMode.kDefault) {
TimedTaskManager.getInstance().cancelTask(blinkTaskID);
switch (newLedMode) {
case kDefault -> setStateImpl(pipelineModeSupplier.getAsBoolean());
case kOff -> setStateImpl(false);

View File

@@ -28,8 +28,11 @@ public record DeviceMetrics(
double gpuMem,
double gpuMemUtil,
double diskUtilPct,
double diskUsableSpace,
double[] npuUsage,
String ipAddress,
double uptime) {
double uptime,
double sentBitRate,
double recvBitRate) {
public static final DeviceMetricsProto proto = new DeviceMetricsProto();
}

View File

@@ -1,306 +0,0 @@
/*
* 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.common.hardware.metrics;
import edu.wpi.first.cscore.CameraServerJNI;
import edu.wpi.first.networktables.NetworkTable;
import edu.wpi.first.networktables.ProtobufPublisher;
import java.io.PrintWriter;
import java.io.StringWriter;
import org.photonvision.common.configuration.ConfigManager;
import org.photonvision.common.configuration.HardwareConfig;
import org.photonvision.common.dataflow.DataChangeService;
import org.photonvision.common.dataflow.events.OutgoingUIEvent;
import org.photonvision.common.dataflow.networktables.NetworkTablesManager;
import org.photonvision.common.hardware.Platform;
import org.photonvision.common.hardware.metrics.cmds.CmdBase;
import org.photonvision.common.hardware.metrics.cmds.FileCmds;
import org.photonvision.common.hardware.metrics.cmds.LinuxCmds;
import org.photonvision.common.hardware.metrics.cmds.PiCmds;
import org.photonvision.common.hardware.metrics.cmds.QCS6490Cmds;
import org.photonvision.common.hardware.metrics.cmds.RK3588Cmds;
import org.photonvision.common.logging.LogGroup;
import org.photonvision.common.logging.Logger;
import org.photonvision.common.networking.NetworkUtils;
import org.photonvision.common.util.ShellExec;
public class MetricsManager {
final Logger logger = new Logger(MetricsManager.class, LogGroup.General);
CmdBase cmds;
ProtobufPublisher<DeviceMetrics> metricPublisher =
NetworkTablesManager.getInstance()
.kRootTable
.getSubTable("/metrics")
.getProtobufTopic(CameraServerJNI.getHostname(), DeviceMetrics.proto)
.publish();
private final ShellExec runCommand = new ShellExec(true, true);
public void setConfig(HardwareConfig config) {
if (config.hasCommandsConfigured()) {
cmds = new FileCmds();
} else if (Platform.isRaspberryPi()) {
cmds = new PiCmds(); // Pi's can use a hardcoded command set
} else if (Platform.isRK3588()) {
cmds = new RK3588Cmds(); // RK3588 chipset hardcoded command set
} else if (Platform.isQCS6490()) {
cmds = new QCS6490Cmds(); // QCS6490 chipset hardcoded command set
} else if (Platform.isLinux()) {
cmds = new LinuxCmds(); // Linux/Unix platforms assume a nominal command set
} else {
cmds = new CmdBase(); // default - base has no commands
}
cmds.initCmds(config);
}
public String safeExecute(String str) {
if (str.isEmpty()) return "";
try {
return execute(str);
} catch (Exception e) {
return "****";
}
}
/**
* Get the CPU temperature in Celsius.
*
* @return The CPU temperature in Celsius, or -1.0 if the command fails or parsing fails.
*/
public double getCpuTemp() {
try {
return Double.parseDouble(safeExecute(cmds.cpuTemperatureCommand));
} catch (NumberFormatException e) {
return -1.0;
}
}
/**
* Get the CPU utilization as a percentage.
*
* @return The CPU utilization as a percentage, or -1.0 if the command fails or parsing fails.
*/
public double getCpuUtilization() {
try {
return Double.parseDouble(safeExecute(cmds.cpuUtilizationCommand));
} catch (NumberFormatException e) {
return -1.0;
}
}
/**
* Get the reason for CPU throttling, if applicable.
*
* @return A string describing the CPU throttle reason, or an empty string if the command fails.
*/
public String getThrottleReason() {
return safeExecute(cmds.cpuThrottleReasonCmd);
}
private double ramMemSave = -2.0;
/**
* Get the total RAM memory in MB. This only runs once, as it won't change over time.
*
* @return The total RAM memory in MB, or -1.0 if the command fails or parsing fails.
*/
public double getRamMem() {
if (ramMemSave == -2.0) {
try {
ramMemSave = Double.parseDouble(safeExecute(cmds.ramMemCommand));
} catch (NumberFormatException e) {
ramMemSave = -1.0;
}
}
return ramMemSave;
}
/**
* Get the RAM utilization in MBs.
*
* @return The RAM utilization in MBs, or -1.0 if the command fails or parsing fails.
*/
public double getRamUtil() {
try {
return Double.parseDouble(safeExecute(cmds.ramUtilCommand));
} catch (NumberFormatException e) {
return -1.0;
}
}
private double gpuMemSave = -2.0;
/**
* Get the total GPU memory in MB. This only runs once, as it won't change over time.
*
* @return The total GPU memory in MB, or -1.0 if the command fails or parsing fails.
*/
public double getGpuMem() {
if (gpuMemSave == -2.0) {
try {
gpuMemSave = Double.parseDouble(safeExecute(cmds.gpuMemCommand));
} catch (NumberFormatException e) {
gpuMemSave = -1.0;
}
}
return gpuMemSave;
}
/**
* Get the GPU memory utilization as MBs.
*
* @return The GPU memory utilization in MBs, or -1.0 if the command fails or parsing fails.
*/
public double getGpuMemUtil() {
try {
return Double.parseDouble(safeExecute(cmds.gpuMemUtilCommand));
} catch (NumberFormatException e) {
return -1.0;
}
}
/**
* Get the percentage of disk space used.
*
* @return The percentage of disk space used, or -1.0 if the command fails or parsing fails.
*/
public double getUsedDiskPct() {
try {
return Double.parseDouble(safeExecute(cmds.diskUsageCommand));
} catch (NumberFormatException e) {
return -1.0;
}
}
// This is here so we don't spam logs if it fails
boolean npuParseWarning = false;
/**
* Get the NPU usage as an array of doubles.
*
* @return An array of doubles representing NPU usage, or null if parsing fails.
*/
public double[] getNpuUsage() {
String[] usages = safeExecute(cmds.npuUsageCommand).split(",");
double[] usageDoubles = new double[usages.length];
for (int i = 0; i < usages.length; i++) {
try {
usageDoubles[i] = Double.parseDouble(usages[i]);
npuParseWarning = false; // Reset warning if parsing succeeds
} catch (NumberFormatException e) {
if (!npuParseWarning) {
logger.error("Failed to parse NPU usage value: " + usages[i], e);
npuParseWarning = true;
}
usageDoubles = new double[0]; // Default to empty array if parsing fails
break;
}
}
return usageDoubles;
}
/**
* Get the IP address of the device.
*
* @return The IP address as a string, or an empty string if the command fails.
*/
public String getIpAddress() {
String dev = ConfigManager.getInstance().getConfig().getNetworkConfig().networkManagerIface;
String addr = NetworkUtils.getIPAddresses(dev);
return addr;
}
/**
* Get the uptime of the device in seconds.
*
* @return The uptime in seconds, or -1.0 if the command fails or parsing fails.
*/
public double getUptime() {
try {
return Double.parseDouble(safeExecute(cmds.uptimeCommand));
} catch (NumberFormatException e) {
return -1.0;
}
}
public void publishMetrics() {
// Check that the hostname hasn't changed
if (!CameraServerJNI.getHostname()
.equals(NetworkTable.basenameKey(metricPublisher.getTopic().getName()))) {
logger.warn("Metrics publisher name does not match hostname! Reinitializing publisher...");
metricPublisher.close();
metricPublisher =
NetworkTablesManager.getInstance()
.kRootTable
.getSubTable("/metrics")
.getProtobufTopic(CameraServerJNI.getHostname(), DeviceMetrics.proto)
.publish();
}
var metrics =
new DeviceMetrics(
this.getCpuTemp(),
this.getCpuUtilization(),
this.getThrottleReason(),
this.getRamMem(),
this.getRamUtil(),
this.getGpuMem(),
this.getGpuMemUtil(),
this.getUsedDiskPct(),
this.getNpuUsage(),
this.getIpAddress(),
this.getUptime());
metricPublisher.set(metrics);
DataChangeService.getInstance().publishEvent(OutgoingUIEvent.wrappedOf("metrics", metrics));
}
public synchronized String execute(String command) {
try {
runCommand.executeBashCommand(command, true, false);
return runCommand.getOutput();
} catch (Exception e) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
e.printStackTrace(pw);
logger.error(
"Command: \""
+ command
+ "\" returned an error!"
+ "\nOutput Received: "
+ runCommand.getOutput()
+ "\nStandard Error: "
+ runCommand.getError()
+ "\nCommand completed: "
+ runCommand.isOutputCompleted()
+ "\nError completed: "
+ runCommand.isErrorCompleted()
+ "\nExit code: "
+ runCommand.getExitCode()
+ "\n Exception: "
+ e
+ sw);
return "";
}
}
}

View File

@@ -0,0 +1,565 @@
/*
* 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.common.hardware.metrics;
import edu.wpi.first.cscore.CameraServerJNI;
import edu.wpi.first.networktables.NetworkTable;
import edu.wpi.first.networktables.ProtobufPublisher;
import java.io.IOException;
import java.nio.file.FileStore;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import org.photonvision.common.configuration.ConfigManager;
import org.photonvision.common.dataflow.DataChangeService;
import org.photonvision.common.dataflow.events.OutgoingUIEvent;
import org.photonvision.common.dataflow.networktables.NetworkTablesManager;
import org.photonvision.common.hardware.Platform;
import org.photonvision.common.logging.LogGroup;
import org.photonvision.common.logging.Logger;
import org.photonvision.common.networking.NetworkUtils;
import org.photonvision.common.util.TimedTaskManager;
import org.photonvision.common.util.file.ProgramDirectoryUtilities;
import oshi.SystemInfo;
import oshi.hardware.CentralProcessor;
import oshi.hardware.CentralProcessor.PhysicalProcessor;
import oshi.hardware.GlobalMemory;
import oshi.hardware.GraphicsCard;
import oshi.hardware.HardwareAbstractionLayer;
import oshi.hardware.NetworkIF;
import oshi.software.os.OperatingSystem;
import oshi.util.FormatUtil;
import oshi.util.GlobalConfig;
public class SystemMonitor {
protected static Logger logger = new Logger(SystemMonitor.class, LogGroup.General);
private static SystemMonitor instance;
private record NetworkTraffic(double sentBitRate, double recvBitRate) {}
ProtobufPublisher<DeviceMetrics> metricPublisher =
NetworkTablesManager.getInstance()
.kRootTable
.getSubTable("/metrics")
.getProtobufTopic(CameraServerJNI.getHostname(), DeviceMetrics.proto)
.publish();
private SystemInfo si;
private CentralProcessor cpu;
private OperatingSystem os;
private GlobalMemory mem;
private HardwareAbstractionLayer hal;
private FileStore fs;
private double totalMemory = -1.0;
private double lastCpuLoad = 0;
private long lastCpuUpdate = 0;
private long[] oldTicks;
private NetworkIF monitoredIFace = null;
private long lastTrafficUpdate = 0;
private long lastBytesSent = 0;
private long lastBytesRecv = 0;
private NetworkTraffic lastResult = new NetworkTraffic(0, 0);
// Set this to true to enable logging the contents of the DeviceMetrics class that is sent to NT
// and the UI.
public boolean writeMetricsToLog = false;
private final String taskName = "SystemMonitorPublisher";
private final double minimumDeltaTime = 0.250; // seconds
private final long mebi = (1024 * 1024);
/**
* Returns the singleton instance of SystemMonitor. Creates the instance, thereby initializing it,
* on the first call.
*
* @return instance of SystemMonitor.
*/
public static SystemMonitor getInstance() {
if (instance == null) {
if (Platform.isRaspberryPi()) {
instance = new SystemMonitorRaspberryPi();
} else if (Platform.isRK3588()) {
instance = new SystemMonitorRK3588();
} else if (Platform.isQCS6490()) {
instance = new SystemMonitorQCS6490();
} else if (Platform.isWindows()) {
instance = new SystemMonitorWindows();
} else {
instance = new SystemMonitor();
}
}
return instance;
}
protected SystemMonitor() {
logger.info("Starting SystemMonitor");
GlobalConfig.set(GlobalConfig.OSHI_OS_WINDOWS_LOADAVERAGE, true);
GlobalConfig.set("oshi.os.linux.sensors.cpuTemperature.types", getThermalZoneTypes());
si = new SystemInfo();
hal = si.getHardware();
os = si.getOperatingSystem();
cpu = hal.getProcessor();
mem = hal.getMemory();
try {
// get the filesystem for the directory photonvision is running in
fs = Files.getFileStore(Path.of(ProgramDirectoryUtilities.getProgramDirectory()));
} catch (IOException e) {
logger.error("Couldn't get FileStore for " + Path.of(""));
fs = null;
}
// initialize CPU monitoring
oldTicks = cpu.getSystemCpuLoadTicks();
// initialize network traffic monitoring
selectNetworkIfByName(
ConfigManager.getInstance().getConfig().getNetworkConfig().networkManagerIface);
}
/**
* Returns a comma-separated list of addtional thermal zone types that should be checked to get
* the CPU temperature on Unix systems. The temperature will be reported for the first temperature
* zone with a type that mateches an item of this list. If the CPU temperature isn't being
* reported correctly for a coprocessor, override this method to return a string with type
* associated with the thermal zone for that comprocessor.
*
* @return String containing a comma-separated list of thermal zone types for reading CPU
* temperature.
*/
protected String getThermalZoneTypes() {
// Find the thermal zone type by logging on to the coprocessor and running:
// `cat /sys/class/thermal/thermal_zone*/type`
// This command will show the types for all thermal zones.
//
return GlobalConfig.get("oshi.os.linux.sensors.cpuTemperature.types");
}
/**
* Starts the periodic system monitor that publishes performance metrics. The metrics are
* published every millisUpdateInerval seconds after a millisStartDelay startup delay. Calling
* this method when the monitor is running will stop it and restart it with the new delay and
* update interval.
*
* @param millisStartDelay the delay before the metrics are first published.
* @param millisUpdateInterval the time between updates in units of milliseconds.
*/
public void startMonitor(long millisStartDelay, long millisUpdateInterval) {
if (TimedTaskManager.getInstance().taskActive(taskName)) {
logger.debug("Stopping running SystemMonitor!");
TimedTaskManager.getInstance().cancelTask(taskName);
}
logger.debug("Starting SystemMonitor with " + millisUpdateInterval + " ms update interval.");
TimedTaskManager.getInstance()
.addTask(taskName, this::publishMetrics, millisStartDelay, millisUpdateInterval);
}
private void publishMetrics() {
// Check that the hostname hasn't changed
if (!CameraServerJNI.getHostname()
.equals(NetworkTable.basenameKey(metricPublisher.getTopic().getName()))) {
logger.warn("Metrics publisher name does not match hostname! Reinitializing publisher...");
metricPublisher.close();
metricPublisher =
NetworkTablesManager.getInstance()
.kRootTable
.getSubTable("/metrics")
.getProtobufTopic(CameraServerJNI.getHostname(), DeviceMetrics.proto)
.publish();
}
var nt = this.getNetworkTraffic();
var metrics =
new DeviceMetrics(
this.getCpuTemperature(),
this.getCpuUsage(),
this.getCpuThrottleReason(),
this.getTotalMemory(),
this.getUsedMemory(),
this.getGpuMem(),
this.getGpuMemUtil(),
this.getUsedDiskPct(),
this.getUsableDiskSpace(),
this.getNpuUsage(),
this.getIpAddress(),
this.getUptime(),
nt.sentBitRate,
nt.recvBitRate);
metricPublisher.set(metrics);
if (writeMetricsToLog) {
logMetrics(metrics);
}
DataChangeService.getInstance().publishEvent(OutgoingUIEvent.wrappedOf("metrics", metrics));
}
private void logMetrics(DeviceMetrics metrics) {
StringBuilder sb = new StringBuilder();
sb.append("System Metrics Update: ");
sb.append(String.format("System Uptime: %.0f, ", metrics.uptime()));
sb.append(String.format("CPU Usage: %.2f%%, ", metrics.cpuUtil()));
sb.append(String.format("CPU Temperature: %.2f °C, ", metrics.cpuTemp()));
sb.append(String.format("NPU Usage: %s, ", Arrays.toString(metrics.npuUsage())));
sb.append(String.format("Used Disk: %.2f%%, ", metrics.diskUtilPct()));
sb.append(String.format("Usable Disk Space: %.0f MiB, ", metrics.diskUsableSpace() / mebi));
sb.append(String.format("Memory: %.0f / %.0f MiB, ", metrics.ramUtil(), metrics.ramMem()));
sb.append(
String.format("GPU Memory: %.0f / %.0f MiB, ", metrics.gpuMemUtil(), metrics.gpuMem()));
sb.append(
String.format("CPU Throttle: %s, ", metrics.cpuThr().isBlank() ? "N/A" : metrics.cpuThr()));
sb.append(
String.format(
"Data sent: %.0f Kbps, Data recieved: %.0f Kbps",
metrics.sentBitRate() / 1000, metrics.recvBitRate() / 1000));
logger.debug(sb.toString());
}
private void resetNetworkTraffic() {
lastBytesSent = monitoredIFace.getBytesSent();
lastBytesRecv = monitoredIFace.getBytesRecv();
lastTrafficUpdate = System.currentTimeMillis();
}
private NetworkIF selectNetworkIfByName(String name) {
if (name.isBlank() || monitoredIFace != null && monitoredIFace.getName().equals(name)) {
return monitoredIFace;
}
for (var iFace : hal.getNetworkIFs()) {
if (iFace.getName().equals(name)) {
logger.debug("Monitoring network traffic on '" + name + "'");
monitoredIFace = iFace;
resetNetworkTraffic();
return iFace;
}
}
logger.warn("Can't monitor network interface '" + name + "'");
return null;
}
/** Writes available information about the hardware to the log. */
public void logSystemInformation() {
var sb = new StringBuilder();
sb.append("*** System Information ***\n");
sb.append("Operating System: " + os.toString() + "\n");
sb.append(" System Uptime: " + FormatUtil.formatElapsedSecs(getUptime()) + "\n");
sb.append(" Elevated Privileges: " + os.isElevated() + "\n");
var computerSystem = hal.getComputerSystem();
sb.append("System: " + computerSystem.toString() + "\n");
sb.append(" Manufacturer: " + computerSystem.getManufacturer() + "\n");
sb.append(" Firmware: " + computerSystem.getFirmware() + "\n");
sb.append(" Baseboard: " + computerSystem.getBaseboard() + "\n");
sb.append(" Model: " + computerSystem.getModel() + "\n");
sb.append(" Serial Number: " + computerSystem.getSerialNumber() + "\n");
sb.append("CPU Info: " + cpu.toString() + "\n");
sb.append(" Max Frequency: " + FormatUtil.formatHertz(cpu.getMaxFreq()) + "\n");
sb.append(
" Current Frequency: "
+ Arrays.stream(cpu.getCurrentFreq())
.mapToObj(FormatUtil::formatHertz)
.collect(Collectors.joining(", "))
+ "\n");
for (PhysicalProcessor core : cpu.getPhysicalProcessors()) {
sb.append(
" Core " + core.getPhysicalProcessorNumber() + " (" + core.getEfficiency() + ")\n");
}
var myProc = os.getCurrentProcess();
sb.append("Current Process: " + myProc.getName() + ", PID: " + myProc.getProcessID() + "\n");
// sb.append(" Command Line: " + myProc.getCommandLine());
sb.append(" Kernel Time: " + myProc.getKernelTime() + "\n");
sb.append(" User Time: " + myProc.getUserTime() + "\n");
sb.append(" Cumulative Load: " + myProc.getProcessCpuLoadCumulative() + "\n");
sb.append(" Up Time: " + myProc.getUpTime() + "\n");
sb.append(" Priority: " + myProc.getPriority() + "\n");
sb.append(" User: " + myProc.getUser() + "\n");
sb.append(" Threads: " + myProc.getThreadCount() + "\n");
sb.append("Network Interfaces\n");
for (NetworkIF iFace : hal.getNetworkIFs()) {
sb.append(" Interface: " + iFace.toString() + "\n");
}
sb.append("Graphics Cards\n");
for (GraphicsCard gc : hal.getGraphicsCards()) {
sb.append(" Card: " + gc.toString() + "\n");
}
logger.info(sb.toString());
}
/**
* Returns the total space available (in bytes) for the filesystem where PhotonVision is running
* (typically "/"). This doesn't report on other mounted filesystems, such as USB sticks.
*
* @return the number of bytes total, or -1 if the command fails.
*/
public long getTotalDiskSpace() {
if (fs != null) {
try {
return fs.getTotalSpace();
} catch (IOException e) {
logger.error("Couldn't retrieve total disk space", e);
}
}
return -1;
}
/**
* Returns the free space available (in bytes) for the filesystem where PhotonVision is running
* (typically "/"). This doesn't report on other mounted filesystems, such as USB sticks.
*
* @return the number of bytes available, or -1 if the command fails.
*/
public long getUsableDiskSpace() {
if (fs != null) {
try {
return fs.getUsableSpace();
} catch (IOException e) {
logger.error("Couldn't retrieve usable disk space", e);
}
}
return -1;
}
/**
* Returns the percentage of disk space used.
*
* @return The percentage of disk space used, or -1.0 if the command fails.
*/
public double getUsedDiskPct() {
double usedPct;
if (fs == null) return -1.0;
try {
double total = fs.getTotalSpace();
// note: df matches better with fs.getUnallocatedSpace(), but this is more conservative
usedPct = 100.0 * (1.0 - fs.getUsableSpace() / total);
} catch (IOException e) {
logger.error("Couldn't retrieve used disk space", e);
usedPct = -1.0;
}
return usedPct;
}
/**
* Returns the temperature of the CPU.
*
* @return The temperature of the CPU in °C or -1.0 if it cannot be retrieved.
*/
public double getCpuTemperature() {
double temperature = hal.getSensors().getCpuTemperature();
// OSHI returns 0 or NaN if the temperature isn't available.
if (temperature == 0.0 || Double.isNaN(temperature)) {
temperature = -1.0;
}
return temperature;
}
/**
* Returns the total RAM.
*
* @return total RAM in MiB.
*/
public double getTotalMemory() {
if (totalMemory < 0) {
totalMemory = mem.getTotal() / mebi;
}
return totalMemory;
}
/**
* Returns the amount of memory in use.
*
* @return the used RAM in MiB.
*/
public double getUsedMemory() {
return (mem.getTotal() - mem.getAvailable()) / mebi;
}
/**
* Returns the time since system boot in seconds.
*
* @return the uptime in seconds.
*/
public long getUptime() {
return os.getSystemUptime();
}
/**
* Returns the average load on the CPU from 0 to 100% since last called by using the tick
* counters.
*
* @return load on the cpu in %.
*/
public synchronized double getCpuUsage() {
long now = System.currentTimeMillis();
double dTime = (now - lastCpuUpdate) / 1000.0;
if (dTime > minimumDeltaTime) {
var newTicks = cpu.getSystemCpuLoadTicks();
lastCpuLoad = 100 * cpu.getSystemCpuLoadBetweenTicks(oldTicks, newTicks);
oldTicks = newTicks;
lastCpuUpdate = now;
}
return lastCpuLoad;
}
/**
* Returns the npu usage, if available. Platforms with NPUs will need to override this method to
* return a useful value.
*
* @return the NPU usage or an empty array if not available.
*/
public double[] getNpuUsage() {
return new double[0];
}
/**
* Returns a description of the CPU throttle state, if available. Platforms that provide this
* information will need to override this method to return a useful value.
*
* @return the CPU throttle state, or an empty String if not available.
*/
public String getCpuThrottleReason() {
return "";
}
/**
* Returns the total GPU memory in MiB.
*
* @return The total GPU memory in MiB, or -1.0 if not avaialable on this platform.
*/
public double getGpuMem() {
return -1.0;
}
/**
* Returns the GPU memory utilization as MiBs.
*
* @return The GPU memory utilization in MiBs, or -1.0 if not available on this platform.
*/
public double getGpuMemUtil() {
return -1.0;
}
/**
* Returns the IP address of the device.
*
* @return The IP address as a string, or an empty string if the command fails.
*/
public String getIpAddress() {
String dev = ConfigManager.getInstance().getConfig().getNetworkConfig().networkManagerIface;
return NetworkUtils.getIPAddresses(dev);
}
/**
* Returns a NetworkTraffic instance containing the average sent and recieved network traffic
* since the last time this was called.
*
* @return NetworkTraffic instance with data in bits/second. The traffic values will be -1 if the
* data isn't available.
*/
private synchronized NetworkTraffic getNetworkTraffic() {
String activeIFaceName =
ConfigManager.getInstance().getConfig().getNetworkConfig().networkManagerIface;
var iFace = selectNetworkIfByName(activeIFaceName);
if (iFace == null) {
return new NetworkTraffic(-1, -1);
}
long now = System.currentTimeMillis();
double dTime = (now - lastTrafficUpdate) / 1000.0;
if (dTime > minimumDeltaTime) {
// only update if it's been long enough since the last update
// otherwise, return the last value
iFace.updateAttributes();
long bytesSent = iFace.getBytesSent();
long bytesRecv = iFace.getBytesRecv();
double sentBitRate = 8 * (bytesSent - lastBytesSent) / dTime;
double recvBitRate = 8 * (bytesRecv - lastBytesRecv) / dTime;
lastBytesSent = bytesSent;
lastBytesRecv = bytesRecv;
lastResult = new NetworkTraffic(sentBitRate, recvBitRate);
lastTrafficUpdate = now;
}
return lastResult;
}
/**
* Benchmarks SystemMonitor by timing the calls to retrieve metrics and writes the results to the
* log.
*/
private void testSM() {
StringBuilder sb = new StringBuilder();
double total = 0;
sb.append("SystemMetrics Test:\n");
total += timeIt(sb, () -> String.format("System Uptime: %d", getUptime()));
total += timeIt(sb, () -> String.format("CPU Usage: %.2f%%", getCpuUsage()));
total += timeIt(sb, () -> String.format("CPU Temperature: %.2f °C", getCpuTemperature()));
total += timeIt(sb, () -> String.format("NPU Usage: %s", Arrays.toString(getNpuUsage())));
total += timeIt(sb, () -> String.format("Used Disk: %.2f%%", getUsedDiskPct()));
total +=
timeIt(
sb, () -> String.format("Usable Disk Space: %.0f MiB, ", getUsableDiskSpace() / mebi));
total +=
timeIt(
sb, () -> String.format("Memory: %.0f / %.0f MiB", getUsedMemory(), getTotalMemory()));
total +=
timeIt(
sb, () -> String.format("GPU Memory: %.0f / %.0f MiB", getGpuMemUtil(), getGpuMem()));
total += timeIt(sb, () -> String.format("CPU Throttle: %s", getCpuThrottleReason()));
total +=
timeIt(
sb,
() -> {
var nt = getNetworkTraffic();
return String.format(
"Data sent: %.0f Kbps, Data recieved: %.0f Kbps",
nt.sentBitRate() / 1000, nt.recvBitRate() / 1000);
});
sb.append(String.format("==========\n%7.3f ms\n", total));
logger.info(sb.toString());
}
/**
* Updates a StringBuilder with the result of calling `source` prepended by the time required to
* run `source`, and returns the time (in ms) that a String Supplier takes. This can be used to
* compare different ways of gathering the same metric.
*
* @param sb A StringBuilder used to collect the output from the supplier.
* @param source A supplier that takes no arguments and returns a String.
* @return The time (in ms) required to produce the output.
*/
private double timeIt(StringBuilder sb, Supplier<String> source) {
long start = System.nanoTime();
String resp = source.get();
var delta = (System.nanoTime() - start) / 1000000.0;
sb.append(String.format(" %7.3f ms >> %s\n", delta, resp));
return delta;
}
}

View File

@@ -15,24 +15,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.photonvision.common.scripting;
package org.photonvision.common.hardware.metrics;
public enum ScriptEventType {
kProgramInit("Program Init"),
kProgramExit("Program Exit"),
kNTConnected("NT Connected"),
kLEDOn("LED On"),
kLEDOff("LED Off"),
kEnterDriverMode("Enter Driver Mode"),
kExitDriverMode("Exit Driver Mode"),
kFoundTarget("Found Target"),
kFoundMultipleTarget("Found Multiple Target"),
kLostTarget("Lost Target"),
kPipelineLag("Pipeline Lag");
public final String value;
ScriptEventType(String value) {
this.value = value;
public class SystemMonitorQCS6490 extends SystemMonitor {
@Override
protected String getThermalZoneTypes() {
return "cpu0-thermal";
}
}

View File

@@ -15,15 +15,20 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.photonvision.common.hardware.metrics.cmds;
package org.photonvision.common.hardware.metrics;
import org.photonvision.common.configuration.HardwareConfig;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class RK3588Cmds extends LinuxCmds {
/** Applies pi-specific commands, ignoring any input configuration */
public void initCmds(HardwareConfig config) {
super.initCmds(config);
public class SystemMonitorRK3588 extends SystemMonitor {
final String regex = "Core\\d:\\s*(\\d+)%";
final Pattern pattern = Pattern.compile(regex);
@Override
protected String getThermalZoneTypes() {
// CPU Temperature
/* The RK3588 chip has 7 thermal zones that can be accessed via:
* /sys/class/thermal/thermal_zoneX/temp
@@ -42,10 +47,19 @@ public class RK3588Cmds extends LinuxCmds {
* - http://forum.armsom.org/t/topic/51/3
* - https://lore.kernel.org/lkml/7276280.TLKafQO6qx@archbook/
*/
cpuTemperatureCommand =
"cat /sys/class/thermal/thermal_zone1/temp | awk '{printf \"%.1f\", $1/1000}'";
return "bigcore0-thermal";
}
npuUsageCommand =
"cat /sys/kernel/debug/rknpu/load | grep -o '[0-9]\\+%' | sed 's/%//g' | paste -sd ','";
@Override
public double[] getNpuUsage() {
try {
var contents = Files.readString(Path.of("/sys/kernel/debug/rknpu/load"));
Matcher matcher = pattern.matcher(contents);
double[] results =
matcher.results().map(mr -> mr.group(1)).mapToDouble(Double::parseDouble).toArray();
return results;
} catch (IOException e) {
return new double[0];
}
}
}

View File

@@ -0,0 +1,83 @@
/*
* 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.common.hardware.metrics;
import java.io.IOException;
import org.photonvision.common.util.ShellExec;
public class SystemMonitorRaspberryPi extends SystemMonitor {
private final ShellExec runCommand = new ShellExec(true, true);
@Override
public String getCpuThrottleReason() {
int state = 0;
String output = vcgencmd("get_throttled");
try {
state = Integer.decode(output);
} catch (NumberFormatException e) {
logger.warn("Could not parse return value: " + output);
}
if ((state & 0x01) != 0) {
return "LOW VOLTAGE";
} else if ((state & 0x08) != 0) {
return "HIGH TEMP";
} else if ((state & 0x10000) != 0) {
return "Prev. Low Voltage";
} else if ((state & 0x80000) != 0) {
return "Prev. High Temp";
}
return "None";
}
@Override
public double getGpuMem() {
String output = vcgencmd("get_mem gpu");
if (!output.isBlank()) {
return Integer.parseInt(output);
}
return -1.0;
}
@Override
public double getGpuMemUtil() {
String output = vcgencmd("get_mem malloc");
if (!output.isBlank()) {
return Integer.parseInt(output);
}
return -1.0;
}
private String vcgencmd(String cmd) {
if (cmd.isBlank()) {
return "";
}
String command = "vcgencmd " + cmd;
try {
runCommand.executeBashCommand(command, true, false);
if (runCommand.getExitCode() != 0) {
logger.error("Bad response from vcgencmd: " + runCommand.getOutput());
return "";
} else {
return runCommand.getOutput().split("=")[1].replaceAll("[^\\d.]$", "");
}
} catch (IOException e) {
logger.error("Could not run `vcgencmd`!", e);
return "";
}
}
}

View File

@@ -0,0 +1,45 @@
/*
* 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.common.hardware.metrics;
public class SystemMonitorWindows extends SystemMonitor {
/**
* Monitoring CPU Temperature on Windows is challenging because most vendors don't publish this
* data to WMI. As a work-around, OSHI tries to use LibreHardwareMonitor via
* jLibreHardwareMonitor. If the temperature isn't found in WMI and jLibreHardwareMonitor isn't
* present, OSHI issues warnings every time getCpuTemperature() is called. This clogs the console
* with useless information when running on Windows and makes testing difficult.
*
* <p>We could include jLibreHardwareMonitor as a dependency for our Windows jar, but
* LibreHardwareMonitor installs Winring0.sys, which is a kernel-level driver with an unfixed
* severe vulnerability. Windows defender flags Winring0 as a vulnerable driver and blocks it from
* installing.
*
* <p>In the end, it isn't worth the risk to include this dependency, so we don't do CPU
* temperature monitoring on Windows.
*
* <p>Threat Information:
* https://www.microsoft.com/en-us/wdsi/threats/threat-search?query=VulnerableDriver:WinNT/Winring0
* Understanding Winring0 vulnerability alert:
* https://windowsforum.com/threads/understanding-microsoft-defenders-vulnerabledriver-winring0-alert-and-how-to-respond.373544/
*/
@Override
public double getCpuTemperature() {
return -1.0;
}
}

View File

@@ -1,43 +0,0 @@
/*
* 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.common.hardware.metrics.cmds;
import org.photonvision.common.configuration.HardwareConfig;
public class CmdBase {
// CPU
public String cpuTemperatureCommand = "";
public String cpuUtilizationCommand = "";
public String cpuThrottleReasonCmd = "";
// RAM
public String ramMemCommand = "";
public String ramUtilCommand = "";
// GPU
public String gpuMemCommand = "";
public String gpuMemUtilCommand = "";
// NPU
public String npuUsageCommand = "";
// Disk
public String diskUsageCommand = "";
// Uptime
public String uptimeCommand = "";
public void initCmds(HardwareConfig config) {
// default - do nothing
}
}

View File

@@ -1,39 +0,0 @@
/*
* 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.common.hardware.metrics.cmds;
import org.photonvision.common.configuration.HardwareConfig;
public class FileCmds extends CmdBase {
@Override
public void initCmds(HardwareConfig config) {
cpuTemperatureCommand = config.cpuTempCommand;
cpuUtilizationCommand = config.cpuUtilCommand;
cpuThrottleReasonCmd = config.cpuThrottleReasonCmd;
ramMemCommand = config.cpuMemoryCommand;
ramUtilCommand = config.ramUtilCommand;
gpuMemCommand = config.gpuMemoryCommand;
gpuMemUtilCommand = config.gpuMemUsageCommand;
diskUsageCommand = config.diskUsageCommand;
uptimeCommand = config.cpuUptimeCommand;
}
}

View File

@@ -1,40 +0,0 @@
/*
* 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.common.hardware.metrics.cmds;
import org.photonvision.common.configuration.HardwareConfig;
public class LinuxCmds extends CmdBase {
public void initCmds(HardwareConfig config) {
// TODO: boards have lots of thermal devices. Hard to pick the CPU
// CPU
cpuUtilizationCommand =
"top -bn1 | grep \"Cpu(s)\" | sed \"s/.*, *\\([0-9.]*\\)%* id.*/\\1/\" | awk '{print 100 - $1}'";
// Uptime
uptimeCommand = "cat /proc/uptime | cut -d ' ' -f1";
// RAM
ramMemCommand = "free -m | awk 'FNR == 2 {print $2}'";
ramUtilCommand = "free -m | awk 'FNR == 2 {print $3}'";
// Disk
diskUsageCommand = "df ./ --output=pcent | tail -n +2 | tr -d '%'";
}
}

View File

@@ -1,40 +0,0 @@
/*
* 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.common.hardware.metrics.cmds;
import org.photonvision.common.configuration.HardwareConfig;
public class PiCmds extends LinuxCmds {
/** Applies pi-specific commands, ignoring any input configuration */
public void initCmds(HardwareConfig config) {
super.initCmds(config);
// CPU
cpuTemperatureCommand = "sed 's/.\\{3\\}$/.&/' /sys/class/thermal/thermal_zone0/temp";
cpuThrottleReasonCmd =
"if (( $(( $(vcgencmd get_throttled | grep -Eo 0x[0-9a-fA-F]*) & 0x01 )) != 0x00 )); then echo \"LOW VOLTAGE\"; "
+ " elif (( $(( $(vcgencmd get_throttled | grep -Eo 0x[0-9a-fA-F]*) & 0x08 )) != 0x00 )); then echo \"HIGH TEMP\"; "
+ " elif (( $(( $(vcgencmd get_throttled | grep -Eo 0x[0-9a-fA-F]*) & 0x10000 )) != 0x00 )); then echo \"Prev. Low Voltage\"; "
+ " elif (( $(( $(vcgencmd get_throttled | grep -Eo 0x[0-9a-fA-F]*) & 0x80000 )) != 0x00 )); then echo \"Prev. High Temp\"; "
+ " else echo \"None\"; fi";
// GPU
gpuMemCommand = "vcgencmd get_mem gpu | grep -Eo '[0-9]+'";
gpuMemUtilCommand = "vcgencmd get_mem malloc | grep -Eo '[0-9]+'";
}
}

View File

@@ -1,40 +0,0 @@
/*
* 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.common.hardware.metrics.cmds;
import org.photonvision.common.configuration.HardwareConfig;
public class QCS6490Cmds extends LinuxCmds {
/** Applies pi-specific commands, ignoring any input configuration */
public void initCmds(HardwareConfig config) {
super.initCmds(config);
/* Thermal zone information can be found in /sys/class/thermal/thermal_zone* directories:
* zone/type: Contains the thermal zone type/name (e.g., "acpi", "x86_pkg_temp")
* zone/temp: Current temperature in millidegrees Celsius (divide by 1000 for actual temp)
* zone/policy: Thermal governor policy (e.g., "step_wise", "power_allocator")
* Each thermal_zone* directory represents a different temperature sensor in the system
*/
cpuTemperatureCommand =
"cat /sys/class/thermal/thermal_zone10/temp | awk '{printf \"%.1f\", $1/1000}'";
// TODO: NPU usage, doesn't seem to be in the same place as the opi. We're gonna just wait on QC
// to get back to us on this one.
}
}

View File

@@ -49,9 +49,12 @@ public class DeviceMetricsProto implements Protobuf<DeviceMetrics, ProtobufDevic
msg.getGpuMem(),
msg.getGpuMemUtil(),
msg.getDiskUtilPct(),
msg.getDiskUsableSpace(),
msg.getNpuUsage().toArray(),
msg.getIpAddress(),
msg.getUptime());
msg.getUptime(),
msg.getSentBitRate(),
msg.getRecvBitRate());
}
@Override
@@ -65,7 +68,10 @@ public class DeviceMetricsProto implements Protobuf<DeviceMetrics, ProtobufDevic
msg.setRamUtil(value.ramUtil());
msg.setGpuMemUtil(value.gpuMemUtil());
msg.setDiskUtilPct(value.diskUtilPct());
msg.setDiskUsableSpace(value.diskUsableSpace());
msg.addAllNpuUsage(value.npuUsage());
msg.setIpAddress(value.ipAddress());
msg.setSentBitRate(value.sentBitRate());
msg.setRecvBitRate(value.recvBitRate());
}
}

View File

@@ -82,23 +82,48 @@ public class NetworkUtils {
private static List<NMDeviceInfo> allInterfaces = null;
private static long lastReadTimestamp = 0;
private static long timeout = 5000; // milliseconds
private static long retry = 500; // milliseconds
public static List<NMDeviceInfo> getAllInterfaces() {
long now = System.currentTimeMillis();
if (now - lastReadTimestamp < 5000) return allInterfaces;
else lastReadTimestamp = now;
public static synchronized List<NMDeviceInfo> getAllInterfaces() {
var start = System.currentTimeMillis();
if (start - lastReadTimestamp < 5000) {
return allInterfaces;
}
var ret = new ArrayList<NMDeviceInfo>();
if (Platform.isLinux()) {
String out = null;
try {
var shell = new ShellExec(true, false);
shell.executeBashCommand(
"nmcli -t -f GENERAL.CONNECTION,GENERAL.DEVICE,GENERAL.TYPE device show", true, false);
out = shell.getOutput();
boolean networkManagerRunning = false;
boolean tryagain = true;
do {
shell.executeBashCommand(
"nmcli -t -f GENERAL.CONNECTION,GENERAL.DEVICE,GENERAL.TYPE device show", true, true);
// nmcli returns an error of 8 if NetworkManager isn't running
networkManagerRunning = shell.getExitCode() != 8;
tryagain = System.currentTimeMillis() - start < timeout;
if (!networkManagerRunning && tryagain) {
logger.debug("NetworkManager not running, retrying in " + (retry) + " milliseconds");
Thread.sleep(retry);
}
} while (!networkManagerRunning && tryagain);
timeout = 0; // only try once after the first time
if (networkManagerRunning) {
out = shell.getOutput();
} else {
logger.error(
"Timed out trying to reach NetworkManager, may not be able to configure networking");
}
} catch (IOException e) {
logger.error("IO Exception occured when calling nmcli to get network interfaces!", e);
} catch (InterruptedException e) {
logger.error("Interrupted while waiting for NetworkManager", e);
}
if (out != null) {
Pattern pattern =
@@ -120,6 +145,8 @@ public class NetworkUtils {
}
allInterfaces = ret;
}
lastReadTimestamp = System.currentTimeMillis();
return ret;
}
@@ -260,11 +287,14 @@ public class NetworkUtils {
}
}
} else { // Managed? We should have a working interface available
byte[] mac = NetworkInterface.getByName(config.networkManagerIface).getHardwareAddress();
if (mac != null) {
return formatMacAddress(mac);
} else {
logger.error("No MAC address found for " + config.networkManagerIface);
var iface = NetworkInterface.getByName(config.networkManagerIface);
if (iface != null) {
byte[] mac = iface.getHardwareAddress();
if (mac != null) {
return formatMacAddress(mac);
} else {
logger.error("No MAC address found for " + config.networkManagerIface);
}
}
}
} catch (Exception e) {

View File

@@ -1,39 +0,0 @@
/*
* 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.common.scripting;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
public class ScriptConfig {
public final ScriptEventType eventType;
public final String command;
public ScriptConfig(ScriptEventType eventType) {
this.eventType = eventType;
this.command = "";
}
@JsonCreator
public ScriptConfig(
@JsonProperty("eventType") ScriptEventType eventType,
@JsonProperty("command") String command) {
this.eventType = eventType;
this.command = command;
}
}

View File

@@ -1,54 +0,0 @@
/*
* 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.common.scripting;
import java.io.IOException;
import org.photonvision.common.logging.LogGroup;
import org.photonvision.common.logging.Logger;
import org.photonvision.common.util.ShellExec;
public class ScriptEvent {
private static final ShellExec executor = new ShellExec(true, true);
public final ScriptConfig config;
private final Logger logger = new Logger(ScriptEvent.class, LogGroup.General);
public ScriptEvent(ScriptConfig config) {
this.config = config;
}
public int run() throws IOException {
int retVal = executor.executeBashCommand(config.command);
String output = executor.getOutput();
String error = executor.getError();
if (!error.isEmpty()) {
logger.error("Error when running \"" + config.eventType.name() + "\" script: " + error);
} else if (!output.isEmpty()) {
logger.info(
String.format("Output from \"%s\" script: %s\n", config.eventType.name(), output));
}
logger.info(
String.format(
"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;
}
}

View File

@@ -1,140 +0,0 @@
/*
* 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.common.scripting;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.LinkedBlockingDeque;
import org.photonvision.common.hardware.Platform;
import org.photonvision.common.logging.LogGroup;
import org.photonvision.common.logging.Logger;
import org.photonvision.common.util.TimedTaskManager;
import org.photonvision.common.util.file.JacksonUtils;
public class ScriptManager {
private static final Logger logger = new Logger(ScriptManager.class, LogGroup.General);
private ScriptManager() {}
private static final List<ScriptEvent> events = new ArrayList<>();
private static final LinkedBlockingDeque<ScriptEventType> queuedEvents =
new LinkedBlockingDeque<>(25);
public static void initialize() {
ScriptConfigManager.initialize();
if (ScriptConfigManager.fileExists()) {
for (ScriptConfig scriptConfig : ScriptConfigManager.loadConfig()) {
ScriptEvent scriptEvent = new ScriptEvent(scriptConfig);
events.add(scriptEvent);
}
TimedTaskManager.getInstance().addTask("ScriptRunner", new ScriptRunner(), 10);
} else {
logger.error("Something went wrong initializing scripts! Events will not run.");
}
}
private static class ScriptRunner implements Runnable {
@Override
public void run() {
try {
handleEvent(queuedEvents.takeFirst());
} catch (InterruptedException e) {
logger.error("ScriptRunner queue interrupted!", e);
}
}
private void handleEvent(ScriptEventType eventType) {
var toRun =
events.parallelStream()
.filter(e -> e.config.eventType == eventType)
.findFirst()
.orElse(null);
if (toRun != null) {
try {
toRun.run();
} catch (IOException e) {
logger.error("Failed to run script for event \"" + eventType.name() + "\"", e);
}
}
}
}
protected static class ScriptConfigManager {
// protected static final Path scriptConfigPath =
// Paths.get(ConfigManager.SettingsPath.toString(), "scripts.json");
static final Path scriptConfigPath = Paths.get(""); // TODO: Waiting on config
private ScriptConfigManager() {}
static boolean fileExists() {
return Files.exists(scriptConfigPath);
}
public static void initialize() {
if (!fileExists()) {
List<ScriptConfig> eventsConfig = new ArrayList<>();
for (var eventType : ScriptEventType.values()) {
eventsConfig.add(new ScriptConfig(eventType));
}
try {
JacksonUtils.serialize(scriptConfigPath, eventsConfig.toArray(new ScriptConfig[0]), true);
} catch (IOException e) {
logger.error("Failed to initialize!", e);
}
}
}
static List<ScriptConfig> loadConfig() {
try {
var raw = JacksonUtils.deserialize(scriptConfigPath, ScriptConfig[].class);
if (raw != null) {
return List.of(raw);
}
} catch (IOException e) {
logger.error("Failed to load scripting config!", e);
}
return new ArrayList<>();
}
protected static void deleteConfig() {
try {
Files.delete(scriptConfigPath);
} catch (IOException e) {
//
}
}
}
public static void queueEvent(ScriptEventType eventType) {
if (Platform.isLinux()) {
try {
queuedEvents.putLast(eventType);
logger.info("Queued event: " + eventType.name());
} catch (InterruptedException e) {
logger.error("Failed to add event to queue: " + eventType.name(), e);
}
}
}
}

View File

@@ -18,6 +18,7 @@
package org.photonvision.common.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import edu.wpi.first.math.geometry.Rotation2d;
import edu.wpi.first.math.geometry.Translation2d;
import edu.wpi.first.math.util.Units;
import java.awt.HeadlessException;
@@ -25,6 +26,7 @@ import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import org.opencv.core.Mat;
import org.opencv.core.Size;
import org.opencv.highgui.HighGui;
import org.photonvision.vision.calibration.CameraCalibrationCoefficients;
import org.photonvision.vision.pipeline.result.CVPipelineResult;
@@ -101,6 +103,26 @@ public class TestUtils {
}
}
public enum WPI2026Images {
// 4000 x 1868 px
// Galaxy S23, 6.3mm focal length
kBlueOutpostFuelSpread;
public static final Size resolution = new Size(4000, 1868);
public static final Rotation2d FOV = Rotation2d.fromDegrees(85.0);
public final Path path;
Path getPath() {
var filename = this.toString().substring(1);
return Path.of("2026", filename + ".jpg");
}
WPI2026Images() {
this.path = getPath();
}
}
public enum WPI2024Images {
kBackAmpZone_117in,
kSpeakerCenter_143in;
@@ -206,7 +228,8 @@ public class TestUtils {
kRobots,
kTag1_640_480,
kTag1_16h5_1280,
kTag_corner_1280;
kTag_corner_1280,
k36h11_stress_test;
public final Path path;
@@ -215,6 +238,7 @@ public class TestUtils {
var filename = this.toString().substring(1).toLowerCase();
var extension = ".jpg";
if (filename.equals("tag1_16h5_1280")) extension = ".png";
if (filename.equals("36h11_stress_test")) extension = ".png";
return Path.of("apriltag", filename + extension);
}

View File

@@ -18,6 +18,7 @@
package org.photonvision.common.util.file;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.StreamReadFeature;
import com.fasterxml.jackson.core.json.JsonReadFeature;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.DeserializationFeature;
@@ -80,6 +81,7 @@ public class JacksonUtils {
pathModule.addKeyDeserializer(Path.class, new PathKeyDeserializer());
return JsonMapper.builder()
.enable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION)
.configure(JsonReadFeature.ALLOW_JAVA_COMMENTS, true)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.JAVA_LANG_OBJECT)

View File

@@ -18,14 +18,23 @@
package org.photonvision.vision.calibration;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import edu.wpi.first.math.geometry.Pose3d;
import java.awt.Color;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import org.jetbrains.annotations.Nullable;
import org.opencv.core.Core;
import org.opencv.core.Mat;
import org.opencv.core.Point;
import org.opencv.core.Point3;
import org.opencv.core.Scalar;
import org.opencv.imgcodecs.Imgcodecs;
import org.opencv.imgproc.Imgproc;
import org.photonvision.common.util.ColorHelper;
// Ignore the previous calibration data that was stored in the json file.
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -47,8 +56,8 @@ public final class BoardObservation implements Cloneable {
public Pose3d optimisedCameraToObject;
// If we should use this observation when re-calculating camera calibration
@JsonProperty("includeObservationInCalibration")
public boolean includeObservationInCalibration;
@JsonProperty("cornersUsed")
public boolean[] cornersUsed;
@JsonProperty("snapshotName")
public String snapshotName;
@@ -63,16 +72,22 @@ public final class BoardObservation implements Cloneable {
@JsonProperty("locationInImageSpace") List<Point> locationInImageSpace,
@JsonProperty("reprojectionErrors") List<Point> reprojectionErrors,
@JsonProperty("optimisedCameraToObject") Pose3d optimisedCameraToObject,
@JsonProperty("includeObservationInCalibration") boolean includeObservationInCalibration,
@JsonProperty("cornersUsed") boolean[] cornersUsed,
@JsonProperty("snapshotName") String snapshotName,
@JsonProperty("snapshotDataLocation") Path snapshotDataLocation) {
this.locationInObjectSpace = locationInObjectSpace;
this.locationInImageSpace = locationInImageSpace;
this.reprojectionErrors = reprojectionErrors;
this.optimisedCameraToObject = optimisedCameraToObject;
this.includeObservationInCalibration = includeObservationInCalibration;
this.snapshotName = snapshotName;
this.snapshotDataLocation = snapshotDataLocation;
// legacy migration -- we assume all points are inliers
if (cornersUsed == null) {
cornersUsed = new boolean[locationInObjectSpace.size()];
Arrays.fill(cornersUsed, true);
}
this.cornersUsed = cornersUsed;
}
@Override
@@ -85,8 +100,8 @@ public final class BoardObservation implements Cloneable {
+ reprojectionErrors
+ ", optimisedCameraToObject="
+ optimisedCameraToObject
+ ", includeObservationInCalibration="
+ includeObservationInCalibration
+ ", cornersUsed="
+ cornersUsed
+ ", snapshotName="
+ snapshotName
+ ", snapshotDataLocation="
@@ -103,4 +118,73 @@ public final class BoardObservation implements Cloneable {
return null;
}
}
@JsonIgnore
/**
* Load the captured board image from disk. Allocates a new Mat, which the caller is responsible
* for releasing.
*
* @return The loaded image, or null if it could not be loaded.
*/
public Mat loadImage() {
Mat img = Imgcodecs.imread(this.snapshotDataLocation.toString());
if (img == null || img.empty() || img.rows() == 0 || img.cols() == 0) {
return null;
}
return img;
}
/**
* Annotate the image with the detected corners, green for used, red for unused
*
* @return Annotated image, or null if the image could not be loaded. Caller is responsible for
* releasing the Mat.
*/
@JsonIgnore
public Mat annotateImage() {
var image = loadImage();
if (image == null) {
return null;
}
int thickness = Core.FILLED;
var diag = Math.hypot(image.width(), image.height());
int r = (int) Math.max(diag * 4.0 / 500.0, 3);
for (int i = 0; i < this.locationInImageSpace.size(); i++) {
var c = locationInImageSpace.get(i);
// -1, -1 means unused corner
if (c.x < 0 || c.y < 0) {
continue;
}
Scalar color;
if (cornersUsed[i]) {
color = ColorHelper.colorToScalar(Color.green);
} else {
color = ColorHelper.colorToScalar(Color.red);
}
Imgproc.circle(image, c, r, color, thickness);
}
return image;
}
/**
* Mean reprojection error for this observation, skipping corners marked as unused. The overall
* mean is calculated as the mean of each individual corner's reprojection error, or the distance
* in pixels between the observed and expected location.
*
* @return Mean reprojection error in pixels.
*/
@JsonIgnore
double meanReprojectionError() {
return reprojectionErrors.stream()
.filter(pt -> cornersUsed[reprojectionErrors.indexOf(pt)])
.mapToDouble(pt -> Math.hypot(pt.x, pt.y))
.average()
.orElse(0);
}
}

View File

@@ -217,7 +217,7 @@ public class CameraCalibrationCoefficients implements Releasable {
}
@JsonIgnore
public List<BoardObservation> getPerViewErrors() {
public List<BoardObservation> getObservations() {
return observations;
}

View File

@@ -18,13 +18,19 @@
package org.photonvision.vision.calibration;
import java.util.List;
import java.util.stream.IntStream;
import org.opencv.core.Size;
public class UICameraCalibrationCoefficients extends CameraCalibrationCoefficients {
public int numSnapshots;
/** Immutable list of mean errors. */
public List<Double> meanErrors;
public List<Integer> numMissing;
public List<Integer> numOutliers;
private static int countOutliers(BoardObservation obs) {
return (int) obs.locationInImageSpace.stream().filter(it -> it.x < 0 || it.y < 0).count();
}
public UICameraCalibrationCoefficients(
Size resolution,
@@ -47,14 +53,19 @@ public class UICameraCalibrationCoefficients extends CameraCalibrationCoefficien
lensmodel);
this.numSnapshots = observations.size();
this.meanErrors =
this.meanErrors = observations.stream().map(BoardObservation::meanReprojectionError).toList();
this.numOutliers =
observations.stream()
.map(
it2 ->
it2.reprojectionErrors.stream()
.mapToDouble(it -> Math.hypot(it.x, it.y))
.average()
.orElse(0))
obs ->
IntStream.range(0, obs.cornersUsed.length)
.filter(i -> !obs.cornersUsed[i])
.map(i -> 1)
.sum()
- countOutliers(obs))
.toList();
this.numMissing =
observations.stream().map(UICameraCalibrationCoefficients::countOutliers).toList();
}
}

View File

@@ -81,6 +81,12 @@ public class TestSource extends VisionSource {
throw new UnsupportedOperationException("Unimplemented method 'requestHsvSettings'");
}
@Override
public void requestBlockForFrames(boolean blockForFrames) {
// TODO Auto-generated method stub
throw new UnsupportedOperationException("Unimplemented method 'requestBlockForFrames'");
}
@Override
public void release() {
// TODO Auto-generated method stub

View File

@@ -167,7 +167,28 @@ public class GenericUSBCameraSettables extends VisionSourceSettables {
}
}
@Override
public void setAutoExposure(boolean cameraAutoExposure) {
if ((configuration.cameraQuirks.hasQuirk(CameraQuirk.ArduOV9281Controls)
|| configuration.cameraQuirks.hasQuirk(CameraQuirk.ArduOV9782Controls))
&& !cameraAutoExposure) {
// OV9281 and OV9782 on Linux seems to sometimes ignore our exposure requests on first boot if
// we're in manual mode. Poking the camera into and out of auto exposure seems to fix it.
try {
setAutoExposureImpl(false);
Thread.sleep(2000);
setAutoExposureImpl(true);
Thread.sleep(2000);
setAutoExposureImpl(false);
} catch (InterruptedException e) {
logger.error("Thread interrupted while setting OV9281 or OV9782 exposure!", e);
}
} else {
setAutoExposureImpl(cameraAutoExposure);
}
}
public void setAutoExposureImpl(boolean cameraAutoExposure) {
logger.debug("Setting auto exposure to " + cameraAutoExposure);
if (!cameraAutoExposure) {

View File

@@ -17,11 +17,15 @@
package org.photonvision.vision.frame;
import org.photonvision.common.logging.LogGroup;
import org.photonvision.common.logging.Logger;
import org.photonvision.common.util.math.MathUtils;
import org.photonvision.vision.opencv.CVMat;
import org.photonvision.vision.opencv.Releasable;
public class Frame implements Releasable {
private static final Logger logger = new Logger(Frame.class, LogGroup.General);
public final long sequenceID;
public final long timestampNanos;
@@ -45,6 +49,15 @@ public class Frame implements Releasable {
this.type = type;
this.timestampNanos = timestampNanos;
this.frameStaticProperties = frameStaticProperties;
logger.trace(
() ->
"Allocated Frame "
+ sequenceID
+ "; color image "
+ colorImage.matId
+ "; processed "
+ processedImage.matId);
}
public Frame(
@@ -73,6 +86,15 @@ public class Frame implements Releasable {
@Override
public void release() {
logger.trace(
() ->
"Releasing Frame "
+ sequenceID
+ "; color image "
+ colorImage.matId
+ "; processed "
+ processedImage.matId);
colorImage.release();
processedImage.release();
}

View File

@@ -59,4 +59,7 @@ public abstract class FrameProvider implements Supplier<Frame>, Releasable {
/** Ask the camera to rotate frames it outputs */
public abstract void requestHsvSettings(HSVPipe.HSVParams params);
/** Ask the camera to block for new frames (true) or use latest available (false) */
public abstract void requestBlockForFrames(boolean blockForFrames);
}

Some files were not shown because too many files have changed in this diff Show More