mirror of
https://github.com/PhotonVision/photonvision
synced 2026-06-20 00:51:41 +00:00
Compare commits
71 Commits
v2026.0.1-
...
v2026.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccbd46be1a | ||
|
|
994dfe77fa | ||
|
|
5a87e4c738 | ||
|
|
284e818e74 | ||
|
|
f4b30da6b3 | ||
|
|
798b01c3a6 | ||
|
|
23392f8d46 | ||
|
|
09e6d45e77 | ||
|
|
77457219c7 | ||
|
|
da88867c60 | ||
|
|
a39844328d | ||
|
|
1b5f4fa802 | ||
|
|
7cc22e52ea | ||
|
|
49629afe9b | ||
|
|
ae74b171aa | ||
|
|
cfd5773e7c | ||
|
|
c348f0e3ba | ||
|
|
4139566514 | ||
|
|
b7a0fad54c | ||
|
|
e73420d62a | ||
|
|
12f74423d9 | ||
|
|
6c9a142622 | ||
|
|
149c214897 | ||
|
|
a952bab4c9 | ||
|
|
bc208bca85 | ||
|
|
dbd6eea4e9 | ||
|
|
afb73b3918 | ||
|
|
9011e285d2 | ||
|
|
8a141904a6 | ||
|
|
121433fd90 | ||
|
|
22567dea74 | ||
|
|
ba4eb621c3 | ||
|
|
43608c5113 | ||
|
|
021053d43e | ||
|
|
3b57125d96 | ||
|
|
e088050902 | ||
|
|
224ce46f14 | ||
|
|
12a8b88b4a | ||
|
|
e4749a3ea9 | ||
|
|
a5dc9ec0d6 | ||
|
|
8e9fe38477 | ||
|
|
5aefb2957d | ||
|
|
1bedadde97 | ||
|
|
de8905ee10 | ||
|
|
6ca7354542 | ||
|
|
3f9e2a9fa8 | ||
|
|
e993cca067 | ||
|
|
a780c9dc41 | ||
|
|
a2b06bd3dd | ||
|
|
db0667f1dc | ||
|
|
eb2b681f24 | ||
|
|
8db1341b79 | ||
|
|
940c3430b8 | ||
|
|
70fed3535e | ||
|
|
5409573f0d | ||
|
|
6b9599d68a | ||
|
|
00ca5e06ba | ||
|
|
d30ae6cc27 | ||
|
|
80d3efe00e | ||
|
|
fddff5dbca | ||
|
|
235e601cbc | ||
|
|
3fbda538f4 | ||
|
|
9d587d5746 | ||
|
|
7a88487131 | ||
|
|
0e33aef8f9 | ||
|
|
6a349aa8bb | ||
|
|
50f2285937 | ||
|
|
90acd19361 | ||
|
|
5362e0cc6c | ||
|
|
ff69ddc247 | ||
|
|
07affd4fe3 |
10
.github/pull_request_template.md
vendored
10
.github/pull_request_template.md
vendored
@@ -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
|
||||
|
||||
371
.github/workflows/build.yml
vendored
371
.github/workflows/build.yml
vendored
@@ -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 }}
|
||||
|
||||
22
.github/workflows/dependency-submission.yml
vendored
Normal file
22
.github/workflows/dependency-submission.yml
vendored
Normal 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
|
||||
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
@@ -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
|
||||
|
||||
30
.github/workflows/lint-format.yml
vendored
30
.github/workflows/lint-format.yml
vendored
@@ -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
|
||||
|
||||
28
.github/workflows/photon-api-docs.yml
vendored
28
.github/workflows/photon-api-docs.yml
vendored
@@ -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 .
|
||||
|
||||
8
.github/workflows/photonvision-rtd.yml
vendored
8
.github/workflows/photonvision-rtd.yml
vendored
@@ -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
|
||||
|
||||
133
.github/workflows/python.yml
vendored
133
.github/workflows/python.yml
vendored
@@ -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
|
||||
|
||||
8
.github/workflows/website.yml
vendored
8
.github/workflows/website.yml
vendored
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.11
|
||||
3.14
|
||||
|
||||
@@ -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:
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -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"
|
||||
}
|
||||
16
.wpiformat
16
.wpiformat
@@ -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/
|
||||
|
||||
38
README.md
38
README.md
@@ -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
|
||||
|
||||
|
||||
30
build.gradle
30
build.gradle
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"supportURL" : "https://limelightvision.io",
|
||||
"ledPins" : [ 13, 18 ],
|
||||
"ledsCanDim" : true,
|
||||
"ledPWMFrequency" : 30000,
|
||||
"ledPWMFrequency" : 1000,
|
||||
"vendorFOV" : 75.76079874010732
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
:::
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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::
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
37
docs/source/docs/programming/photonlib/fps-limiter.md
Normal file
37
docs/source/docs/programming/photonlib/fps-limiter.md
Normal 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)
|
||||
```
|
||||
@@ -9,4 +9,5 @@ using-target-data
|
||||
robot-pose-estimator
|
||||
driver-mode-pipeline-index
|
||||
controlling-led
|
||||
fps-limiter
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
:::
|
||||
::::
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
27
photon-client/pnpm-lock.yaml
generated
27
photon-client/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
510
photon-client/src/components/settings/DeviceCard.vue
Normal file
510
photon-client/src/components/settings/DeviceCard.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>({
|
||||
|
||||
@@ -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>
|
||||
236
photon-client/src/components/settings/MetricsChart.vue
Normal file
236
photon-client/src/components/settings/MetricsChart.vue
Normal 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>
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
24
photon-client/src/lib/ThreeUtils.ts
Normal file
24
photon-client/src/lib/ThreeUtils.ts
Normal 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);
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface WebsocketCameraSettingsUpdate {
|
||||
minWhiteBalanceTemp: number;
|
||||
maxWhiteBalanceTemp: number;
|
||||
matchedCameraInfo: PVCameraInfo;
|
||||
fpsLimit: number;
|
||||
isConnected: boolean;
|
||||
hasConnected: boolean;
|
||||
mismatch: boolean;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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="
|
||||
|
||||
@@ -218,7 +218,7 @@ class LegacyConfigProvider extends ConfigProvider {
|
||||
hardwareSettings,
|
||||
networkConfig,
|
||||
atfl,
|
||||
new NeuralNetworkPropertyManager(),
|
||||
new NeuralNetworkModelsSettings(),
|
||||
cameraConfigurations);
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 '%'";
|
||||
}
|
||||
}
|
||||
@@ -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]+'";
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ public class CameraCalibrationCoefficients implements Releasable {
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public List<BoardObservation> getPerViewErrors() {
|
||||
public List<BoardObservation> getObservations() {
|
||||
return observations;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user