mirror of
https://github.com/PhotonVision/photonvision
synced 2026-06-19 00:41:41 +00:00
Compare commits
94 Commits
| 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 | ||
|
|
a585a1d339 | ||
|
|
9490c2f2cd | ||
|
|
467f22bfdc | ||
|
|
c2433e0332 | ||
|
|
1bb05a0e3e | ||
|
|
0c4c310c66 | ||
|
|
98f4a8642f | ||
|
|
88d0f16e80 | ||
|
|
6285f1ee24 | ||
|
|
192e2a115c | ||
|
|
1c802f5eca | ||
|
|
d44d9fbbeb | ||
|
|
9e45e8b80a | ||
|
|
9d7222a19e | ||
|
|
674f6e2361 | ||
|
|
5e830fae57 | ||
|
|
16751e9ecd | ||
|
|
b7054f2a6f | ||
|
|
3ab8bc0e5d | ||
|
|
ca0923e27f | ||
|
|
369d10eb91 | ||
|
|
017b074eae | ||
|
|
f821657d2b |
11
.github/pull_request_template.md
vendored
11
.github/pull_request_template.md
vendored
@@ -1,19 +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_
|
||||
- [ ] This PR has been [linted](https://docs.photonvision.org/en/latest/docs/contributing/linting.html).
|
||||
- [ ] 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
|
||||
|
||||
542
.github/workflows/build.yml
vendored
542
.github/workflows/build.yml
vendored
@@ -10,7 +10,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
IMAGE_VERSION: v2026.0.4
|
||||
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,52 +28,84 @@ 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
|
||||
- name: Install RoboRIO Toolchain
|
||||
run: ./gradlew installRoboRioToolchain
|
||||
# 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
|
||||
- name: Delete duplicate toolchains
|
||||
run: |
|
||||
find ~/.gradle/cache/ -name *roborio-academic* -exec rm -rf {} +
|
||||
du -h . | sort -h
|
||||
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: ./gradlew build
|
||||
run: |
|
||||
./gradlew build
|
||||
./gradlew clean
|
||||
- name: Build C++ examples
|
||||
working-directory: photonlib-cpp-examples
|
||||
run: ./gradlew build
|
||||
build-gradle:
|
||||
name: "Gradle Build"
|
||||
runs-on: ubuntu-22.04
|
||||
run: |
|
||||
./gradlew build
|
||||
./gradlew clean
|
||||
|
||||
playwright-tests:
|
||||
name: "Playwright E2E tests"
|
||||
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
|
||||
@@ -81,23 +114,62 @@ 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
|
||||
working-directory: photon-client
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm test-setup
|
||||
- name: Prebuild Gradle
|
||||
run: ./gradlew photon-targeting:build photon-core:build photon-server:build -x check
|
||||
- name: Run Playwright tests
|
||||
working-directory: photon-client
|
||||
run: pnpm test
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: photon-client/playwright-report/
|
||||
retention-days: 30
|
||||
build-gradle:
|
||||
name: "Gradle Build"
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [validation]
|
||||
steps:
|
||||
# Checkout code.
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Fetch tags
|
||||
run: git fetch --tags --force
|
||||
- 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
|
||||
- name: Install mrcal deps
|
||||
run: sudo apt-get update && sudo apt-get install -y libcholmod3 liblapack3 libsuitesparseconfig5
|
||||
- name: Gradle Build
|
||||
run: ./gradlew photon-targeting:build photon-core:build photon-server:build -x check
|
||||
- name: Gradle Tests and Coverage
|
||||
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
|
||||
@@ -112,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
|
||||
@@ -142,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
|
||||
@@ -186,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
|
||||
@@ -206,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
|
||||
@@ -227,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
|
||||
@@ -235,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
|
||||
@@ -252,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:
|
||||
@@ -266,314 +410,226 @@ 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 }}
|
||||
# On linux, install mrcal packages
|
||||
- run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes libcholmod3 liblapack3 libsuitesparseconfig5
|
||||
if: ${{ (matrix.os) == 'ubuntu-24.04' }}
|
||||
# and actually run the jar
|
||||
- run: java -jar ${{ matrix.extraOpts }} *.jar --smoketest
|
||||
if: ${{ (matrix.os) != 'windows-latest' }}
|
||||
- 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:
|
||||
- os: ubuntu-24.04
|
||||
artifact-name: LinuxArm64
|
||||
image_suffix: RaspberryPi
|
||||
image_url: https://github.com/PhotonVision/photon-image-modifier/releases/download/$IMAGE_VERSION/photonvision_raspi.img.xz
|
||||
cpu: cortex-a7
|
||||
image_additional_mb: 0
|
||||
extraOpts: -Djdk.lang.Process.launchMechanism=vfork
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: smoketest-${{ matrix.image_suffix }}
|
||||
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: jar-${{ matrix.artifact-name }}
|
||||
|
||||
- uses: pguyot/arm-runner-action@v2
|
||||
name: Run photon smoketest
|
||||
id: generate_image
|
||||
with:
|
||||
base_image: ${{ matrix.image_url }}
|
||||
image_additional_mb: ${{ matrix.image_additional_mb }}
|
||||
optimize_image: yes
|
||||
cpu: ${{ matrix.cpu }}
|
||||
# We do _not_ wanna copy photon into the image. Bind mount instead
|
||||
bind_mount_repository: true
|
||||
# our image better have java installed already
|
||||
commands: |
|
||||
java -jar ${{ matrix.extraOpts }} *.jar --smoketest
|
||||
|
||||
build-image:
|
||||
needs: [build-package]
|
||||
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
needs: [build-package-linux]
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-24.04
|
||||
- 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
|
||||
cpu: cortex-a7
|
||||
image_additional_mb: 0
|
||||
- os: ubuntu-24.04
|
||||
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
|
||||
cpu: cortex-a7
|
||||
image_additional_mb: 0
|
||||
- os: ubuntu-24.04
|
||||
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
|
||||
cpu: cortex-a7
|
||||
image_additional_mb: 0
|
||||
- os: ubuntu-24.04
|
||||
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
|
||||
cpu: cortex-a7
|
||||
image_additional_mb: 0
|
||||
- os: ubuntu-24.04
|
||||
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
|
||||
cpu: cortex-a76
|
||||
image_additional_mb: 0
|
||||
- os: ubuntu-24.04
|
||||
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
|
||||
cpu: cortex-a76
|
||||
image_additional_mb: 0
|
||||
- os: ubuntu-24.04
|
||||
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
|
||||
cpu: cortex-a8
|
||||
image_additional_mb: 1024
|
||||
- os: ubuntu-24.04
|
||||
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
|
||||
cpu: cortex-a8
|
||||
image_additional_mb: 1024
|
||||
- os: ubuntu-24.04
|
||||
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
|
||||
cpu: cortex-a8
|
||||
image_additional_mb: 1024
|
||||
- os: ubuntu-24.04
|
||||
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
|
||||
cpu: cortex-a8
|
||||
image_additional_mb: 1024
|
||||
- os: ubuntu-24.04
|
||||
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
|
||||
cpu: cortex-a8
|
||||
image_additional_mb: 1024
|
||||
- os: ubuntu-24.04
|
||||
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
|
||||
cpu: cortex-a8
|
||||
image_additional_mb: 1024
|
||||
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'
|
||||
shrink_image: 'no'
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: "Build image - ${{ matrix.image_suffix }}"
|
||||
|
||||
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: pguyot/arm-runner-action@HEAD
|
||||
- uses: photonvision/photon-image-runner@HEAD
|
||||
name: Generate image
|
||||
id: generate_image
|
||||
with:
|
||||
base_image: ${{ matrix.image_url }}
|
||||
image_additional_mb: ${{ matrix.image_additional_mb }}
|
||||
optimize_image: yes
|
||||
cpu: ${{ matrix.cpu }}
|
||||
# We do _not_ wanna copy photon into the image. Bind mount instead
|
||||
bind_mount_repository: true
|
||||
commands: |
|
||||
chmod +x scripts/armrunner.sh
|
||||
./scripts/armrunner.sh
|
||||
image_url: ${{ matrix.image_url }}
|
||||
minimum_free_mb: ${{ matrix.minimum_free_mb }}
|
||||
root_location: ${{ matrix.root_location || 'partition=2' }}
|
||||
shrink_image: ${{ matrix.shrink_image || 'yes' }}
|
||||
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}")
|
||||
mv ${{ steps.generate_image.outputs.image }} $new_image_name
|
||||
sudo xz -T 0 -v $new_image_name
|
||||
- uses: actions/upload-artifact@v4
|
||||
sudo mv ${{ steps.generate_image.outputs.image }} $new_image_name
|
||||
sudo xz -T 0 -kv $new_image_name
|
||||
echo "smoketest_image_loc=${new_image_name}" >> $GITHUB_ENV
|
||||
|
||||
- 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))
|
||||
tardir=$(basename "${new_jar/.jar/_${{ matrix.image_suffix }}.img}")
|
||||
imagedir=$(dirname ${{ steps.generate_image.outputs.image }})
|
||||
sudo mkdir --parents ${tardir}
|
||||
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@v6
|
||||
name: Upload image
|
||||
with:
|
||||
name: image-${{ matrix.image_suffix }}
|
||||
path: photonvision*.xz
|
||||
build-rubik-image:
|
||||
needs: [build-package]
|
||||
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
runs-on: ubuntu-24.04
|
||||
name: "Build image - Rubik Pi 3"
|
||||
# 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:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: jar-LinuxArm64
|
||||
- name: Generate image
|
||||
run: |
|
||||
wget https://raw.githubusercontent.com/PhotonVision/photon-image-modifier/refs/tags/$IMAGE_VERSION/mount_rubikpi3.sh
|
||||
chmod +x mount_rubikpi3.sh
|
||||
./mount_rubikpi3.sh https://github.com/PhotonVision/photon-image-modifier/releases/download/$IMAGE_VERSION/photonvision_rubikpi3.tar.xz /tmp/build/scripts/armrunner.sh
|
||||
- name: Compress image
|
||||
run: |
|
||||
new_jar=$(realpath $(find . -name photonvision\*-linuxarm64.jar))
|
||||
new_image_name=$(basename "${new_jar/.jar/_rubikpi3.img}")
|
||||
mv photonvision_rubikpi3 $new_image_name
|
||||
tar -I 'xz -T0' -cf ${new_image_name}.tar.xz $new_image_name --checkpoint=10000 --checkpoint-action=echo='%T'
|
||||
- uses: actions/upload-artifact@v4
|
||||
name: Upload image
|
||||
with:
|
||||
name: image-rubikpi3
|
||||
path: photonvision*.xz
|
||||
- run: ${{!contains(needs.*.result, 'failure')}}
|
||||
|
||||
release:
|
||||
needs: [build-photonlib-vendorjson, build-package, build-image, build-rubik-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-*
|
||||
@@ -594,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
|
||||
|
||||
67
.github/workflows/lint-format.yml
vendored
67
.github/workflows/lint-format.yml
vendored
@@ -14,32 +14,39 @@ 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
|
||||
run: git --no-pager diff --exit-code HEAD
|
||||
run: |
|
||||
set +e
|
||||
git --no-pager diff --exit-code HEAD
|
||||
exit_code=$?
|
||||
if test "$exit_code" -ne "0"; then
|
||||
echo "::error ::Linting failed. See https://docs.photonvision.org/en/latest/docs/contributing/linting.html"
|
||||
exit $exit_code
|
||||
fi
|
||||
- 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
|
||||
@@ -47,32 +54,38 @@ 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
|
||||
- run: ./gradlew spotlessCheck
|
||||
- run: |
|
||||
set +e
|
||||
./gradlew spotlessCheck
|
||||
exit_code=$?
|
||||
if test "$exit_code" -ne "0"; then
|
||||
echo "::error ::Linting failed. See https://docs.photonvision.org/en/latest/docs/contributing/linting.html"
|
||||
exit $exit_code
|
||||
fi
|
||||
name: Run spotless
|
||||
|
||||
client-lint-format:
|
||||
name: "PhotonClient Lint and Formatting"
|
||||
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
|
||||
@@ -80,6 +93,20 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: pnpm i --frozen-lockfile
|
||||
- name: Check Linting
|
||||
run: pnpm run lint-ci
|
||||
run: |
|
||||
set +e
|
||||
pnpm run lint-ci
|
||||
exit_code=$?
|
||||
if test "$exit_code" -ne "0"; then
|
||||
echo "::error ::Linting failed. See https://docs.photonvision.org/en/latest/docs/contributing/linting.html"
|
||||
exit $exit_code
|
||||
fi
|
||||
- name: Check Formatting
|
||||
run: pnpm run format-ci
|
||||
run: |
|
||||
set +e
|
||||
pnpm run format-ci
|
||||
exit_code=$?
|
||||
if test "$exit_code" -ne "0"; then
|
||||
echo "::error ::Linting failed. See https://docs.photonvision.org/en/latest/docs/contributing/linting.html"
|
||||
exit $exit_code
|
||||
fi
|
||||
|
||||
66
.github/workflows/photon-api-docs.yml
vendored
66
.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,24 +44,24 @@ 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: demo
|
||||
name: built-demo
|
||||
path: photon-client/dist/
|
||||
|
||||
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,53 +69,23 @@ 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
|
||||
|
||||
run_py_docs:
|
||||
name: Build Python API Docs
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r photon-lib/py/docs/requirements.txt
|
||||
|
||||
- name: Build Sphinx site
|
||||
run: |
|
||||
sphinx-apidoc -o docs/source photonlibpy
|
||||
make -C docs html
|
||||
working-directory: photon-lib/py
|
||||
|
||||
- name: Upload built site as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docs-python
|
||||
path: photon-lib/py/docs/build/html
|
||||
|
||||
publish_api_docs:
|
||||
name: Publish API Docs
|
||||
needs: [ run_java_cpp_docs, run_py_docs ]
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [run_java_cpp_docs]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
# Download docs artifact
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: docs-*
|
||||
- run: find .
|
||||
- name: Publish Docs To Development
|
||||
# if: github.ref == 'refs/heads/main'
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: up9cloud/action-rsync@v1.4
|
||||
env:
|
||||
HOST: ${{ secrets.WEBMASTER_SSH_HOST }}
|
||||
@@ -134,11 +104,11 @@ 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: demo
|
||||
name: built-demo
|
||||
- run: find .
|
||||
- name: Publish demo
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
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 mkdocs mkdocs-gen-files
|
||||
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
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -142,13 +142,17 @@ venv
|
||||
.venv/*
|
||||
.venv
|
||||
networktables.json
|
||||
|
||||
# Web stuff
|
||||
photon-server/src/main/resources/web/*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
components.d.ts
|
||||
|
||||
# Py docs stuff
|
||||
photon-lib/py/docs/build
|
||||
photon-server/src/main/resources/web/index.html
|
||||
|
||||
# Playwright
|
||||
photon-client/test-results/
|
||||
photon-client/playwright-report/
|
||||
photon-client/blob-report/
|
||||
photon-client/playwright/.cache/
|
||||
photon-client/playwright/.auth/
|
||||
|
||||
@@ -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/
|
||||
|
||||
47
README.md
47
README.md
@@ -8,20 +8,19 @@ 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.
|
||||
|
||||
## 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)
|
||||
|
||||
## 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: [http://photonvision.global/](http://photonvision.global/)
|
||||
- Javadocs: [javadocs.photonvision.org](https://javadocs.photonvision.org)
|
||||
- C++ Doxygen [cppdocs.photonvision.org](https://cppdocs.photonvision.org)
|
||||
- Python Documentation [pydocs.photonvision.org](https://pydocs.photonvision.org)
|
||||
|
||||
## Building
|
||||
|
||||
Gradle is used for all C++ and Java code, and pnpm is used for the web UI. Instructions to compile PhotonVision yourself can be found [in our docs](https://docs.photonvision.org/en/latest/docs/contributing/building-photon.html#compiling-instructions).
|
||||
@@ -33,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
|
||||
@@ -47,39 +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
|
||||
|
||||
## Additional packages
|
||||
|
||||
For now, using mrcal requires installing these additional packages on Linux systems:
|
||||
|
||||
```
|
||||
sudo apt install libcholmod3 liblapack3 libsuitesparseconfig5
|
||||
```
|
||||
- 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 "2025.3.2"
|
||||
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 = "2025.3.2"
|
||||
wpilibVersion = "2026.2.1"
|
||||
wpimathVersion = wpilibVersion
|
||||
openCVYear = "2025"
|
||||
openCVversion = "4.10.0-3"
|
||||
javalinVersion = "6.7.0"
|
||||
libcameraDriverVersion = "v2025.0.4"
|
||||
rknnVersion = "dev-v2025.0.0-5-g666c0c6"
|
||||
rubikVersion = "dev-v2025.1.0-6-g4a5e508"
|
||||
frcYear = "2025"
|
||||
mrcalVersion = "v2025.0.0";
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
165
devTools/photon.lua
Normal file
165
devTools/photon.lua
Normal file
@@ -0,0 +1,165 @@
|
||||
-- PhotonVision Time Synchronization Protocol Dissector
|
||||
-- Protocol runs on UDP port 5810
|
||||
-- Reference: https://docs.photonvision.org/en/v2026.0.0-alpha-1/docs/contributing/design-descriptions/time-sync.html
|
||||
|
||||
photon_timesync_proto = Proto("photon_timesync", "PhotonVision Time Sync Protocol")
|
||||
|
||||
-- Protocol fields
|
||||
local pf_version = ProtoField.uint8("photon_timesync.version", "Version", base.DEC)
|
||||
local pf_message_id = ProtoField.uint8("photon_timesync.message_id", "Message ID", base.DEC, {
|
||||
[0] = "Ping",
|
||||
[1] = "Pong"
|
||||
})
|
||||
local pf_client_time = ProtoField.uint64("photon_timesync.client_time", "Client Time (μs)", base.DEC)
|
||||
local pf_server_time = ProtoField.uint64("photon_timesync.server_time", "Server Time (μs)", base.DEC)
|
||||
local pf_response_in = ProtoField.framenum("photon_timesync.response_in", "Response In Frame", base.NONE,
|
||||
frametype.RESPONSE)
|
||||
local pf_response_to = ProtoField.framenum("photon_timesync.response_to", "Response To Frame", base.NONE,
|
||||
frametype.REQUEST)
|
||||
local pf_response_time = ProtoField.relative_time("photon_timesync.response_time", "Response Time")
|
||||
|
||||
-- Register fields
|
||||
photon_timesync_proto.fields = {
|
||||
pf_version,
|
||||
pf_message_id,
|
||||
pf_client_time,
|
||||
pf_server_time,
|
||||
pf_response_in,
|
||||
pf_response_to,
|
||||
pf_response_time
|
||||
}
|
||||
|
||||
-- Table to track ping/pong relationships
|
||||
-- Key: client_time as string, Value: frame number of ping
|
||||
local ping_table = {}
|
||||
-- Table to store pong responses for pings
|
||||
-- Key: ping frame number, Value: pong frame number
|
||||
local pong_table = {}
|
||||
|
||||
-- Dissector function
|
||||
function photon_timesync_proto.dissector(buffer, pinfo, tree)
|
||||
-- Check if buffer has minimum length (TspPing = 10 bytes)
|
||||
local length = buffer:len()
|
||||
if length < 10 then
|
||||
return 0
|
||||
end
|
||||
|
||||
-- Set protocol column
|
||||
pinfo.cols.protocol = photon_timesync_proto.name
|
||||
|
||||
-- Create protocol tree
|
||||
local subtree = tree:add(photon_timesync_proto, buffer(), "PhotonVision Time Sync Protocol Data")
|
||||
|
||||
-- Parse version (1 byte)
|
||||
local version = buffer(0, 1):uint()
|
||||
subtree:add(pf_version, buffer(0, 1))
|
||||
|
||||
-- Parse message_id (1 byte)
|
||||
local msg_id = buffer(1, 1):uint()
|
||||
subtree:add(pf_message_id, buffer(1, 1))
|
||||
|
||||
-- Parse client_time (8 bytes, little-endian uint64)
|
||||
local client_time = buffer(2, 8):le_uint64()
|
||||
subtree:add_le(pf_client_time, buffer(2, 8))
|
||||
|
||||
-- Convert client_time to string for use as key
|
||||
local client_time_key = tostring(client_time)
|
||||
local frame_num = pinfo.number
|
||||
|
||||
-- Track relationships between ping and pong
|
||||
if not pinfo.visited then
|
||||
-- First pass: build the relationship tables
|
||||
if msg_id == 1 then
|
||||
-- This is a Ping - store it
|
||||
ping_table[client_time_key] = frame_num
|
||||
elseif msg_id == 2 then
|
||||
-- This is a Pong - find matching Ping
|
||||
local ping_frame = ping_table[client_time_key]
|
||||
if ping_frame then
|
||||
pong_table[ping_frame] = frame_num
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Update info column and parse based on message type
|
||||
if msg_id == 1 then
|
||||
-- TspPing: version(1) + message_id(1) + client_time(8) = 10 bytes
|
||||
pinfo.cols.info = string.format("Time Sync Ping (client_time: %s μs)", tostring(client_time))
|
||||
|
||||
-- Check if we have a response for this ping
|
||||
local pong_frame = pong_table[frame_num]
|
||||
if pong_frame then
|
||||
local response_item = subtree:add(pf_response_in, pong_frame)
|
||||
response_item:set_generated()
|
||||
end
|
||||
elseif msg_id == 2 then
|
||||
-- TspPong: TspPing + server_time(8) = 18 bytes
|
||||
pinfo.cols.info = "Time Sync Pong"
|
||||
|
||||
if length >= 18 then
|
||||
local server_time = buffer(10, 8):le_uint64()
|
||||
subtree:add_le(pf_server_time, buffer(10, 8))
|
||||
pinfo.cols.info = string.format("Time Sync Pong (client: %s, server: %s μs)",
|
||||
tostring(client_time), tostring(server_time))
|
||||
|
||||
-- Find the matching ping frame
|
||||
local ping_frame = ping_table[client_time_key]
|
||||
if ping_frame then
|
||||
local request_item = subtree:add(pf_response_to, ping_frame)
|
||||
request_item:set_generated()
|
||||
|
||||
-- Calculate response time if we can get the ping packet
|
||||
local ping_time = pinfo.abs_ts - pinfo.rel_ts
|
||||
-- Note: This is an approximation. For accurate timing, we'd need to
|
||||
-- store the timestamp of the ping packet
|
||||
end
|
||||
end
|
||||
else
|
||||
pinfo.cols.info = string.format("Time Sync Unknown (ID: %d)", msg_id)
|
||||
end
|
||||
|
||||
return length
|
||||
end
|
||||
|
||||
-- Register dissector on UDP port 5810
|
||||
local udp_port = DissectorTable.get("udp.port")
|
||||
udp_port:add(5810, photon_timesync_proto)
|
||||
|
||||
-- Heuristic dissector function
|
||||
local function heuristic_checker(buffer, pinfo, tree)
|
||||
local length = buffer:len()
|
||||
|
||||
-- Check minimum length (TspPing = 10 bytes)
|
||||
if length < 10 then
|
||||
return false
|
||||
end
|
||||
|
||||
local version = buffer(0, 1):uint()
|
||||
local msg_id = buffer(1, 1):uint()
|
||||
|
||||
-- Check if this looks like our protocol
|
||||
-- Version should be reasonable (0-10), message_id should be 1 or 2
|
||||
if version <= 10 and (msg_id == 1 or msg_id == 2) then
|
||||
-- Validate packet structure
|
||||
if msg_id == 1 and length == 10 then
|
||||
-- TspPing is exactly 10 bytes
|
||||
photon_timesync_proto.dissector(buffer, pinfo, tree)
|
||||
return true
|
||||
elseif msg_id == 2 and length == 18 then
|
||||
-- TspPong is exactly 18 bytes
|
||||
photon_timesync_proto.dissector(buffer, pinfo, tree)
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
-- Register heuristic dissector
|
||||
photon_timesync_proto:register_heuristic("udp", heuristic_checker)
|
||||
|
||||
-- Initialize function to reset tables on new capture
|
||||
function photon_timesync_proto.init()
|
||||
ping_table = {}
|
||||
pong_table = {}
|
||||
end
|
||||
@@ -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,7 +3,6 @@
|
||||
"supportURL" : "https://limelightvision.io",
|
||||
"ledPins" : [ 13, 18 ],
|
||||
"ledsCanDim" : true,
|
||||
"ledPWMRange" : [ 0, 100 ],
|
||||
"ledPWMFrequency" : 30000,
|
||||
"ledPWMFrequency" : 1000,
|
||||
"vendorFOV" : 75.76079874010732
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ VERY Limited macOS support is available.
|
||||
|
||||
## Installing Java
|
||||
|
||||
PhotonVision requires a JDK installed and on the system path. JDK 17 is needed (different versions will not work). You may already have this if you have installed WPILib 2025+. If not, [download and install it from here](https://adoptium.net/temurin/releases?version=17).
|
||||
PhotonVision requires a JDK installed and on the system path. JDK 17 is needed (different versions will not work). You may already have this if you have installed WPILib 2026+. If not, [download and install it from here](https://adoptium.net/temurin/releases?version=17).
|
||||
|
||||
:::{warning}
|
||||
Using a JDK other than JDK17 will cause issues when running PhotonVision and is not supported.
|
||||
|
||||
@@ -12,15 +12,7 @@ Bonjour provides more stable networking when using Windows PCs. Install [Bonjour
|
||||
|
||||
## Installing Java
|
||||
|
||||
PhotonVision requires a JDK installed and on the system path. **JDK 17 is needed. Windows Users must use the JDK that ships with WPILib.** [Download and install it from here.](https://github.com/wpilibsuite/allwpilib/releases/tag/v2025.3.2) Either ensure the only Java on your PATH is the WPILIB Java or specify it to gradle with `-Dorg.gradle.java.home=C:\Users\Public\wpilib\2025\jdk`:
|
||||
|
||||
```
|
||||
> ./gradlew run "-Dorg.gradle.java.home=C:\Users\Public\wpilib\2025\jdk"
|
||||
```
|
||||
|
||||
:::{warning}
|
||||
Using a JDK other than WPILIB's JDK17 will cause issues when running PhotonVision and is not supported.
|
||||
:::
|
||||
PhotonVision requires a JDK installed and on the system path. **JDK 17 is needed.** You may already have it if you installed WPILib, but ensure that running `java -version` shows JDK 17. You will likely have to add WPILib's JDK to JAVA_HOME and the JDK's `bin` directory to PATH. If you do not have a JDK 17 install, [download and install it from here.](https://adoptium.net/temurin/releases?version=17)
|
||||
|
||||
## Downloading the Latest Stable Release of PhotonVision
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@ This section contains the build instructions from the source code available at [
|
||||
|
||||
**Java Development Kit:**
|
||||
|
||||
This project requires Java Development Kit (JDK) 17 to be compiled. This is the same Java version that comes with WPILib for 2025+. **Windows Users must use the JDK that ships with WPILib.** For other platforms, you can follow the instructions to install JDK 17 for your platform [here](https://bell-sw.com/pages/downloads/#jdk-17-lts).
|
||||
This project requires Java Development Kit (JDK) 17 to be compiled. This is the same Java version that comes with WPILib for 2026+. **Windows Users must use the JDK that ships with WPILib.** For other platforms, you can follow the instructions to install JDK 17 for your platform [here](https://bell-sw.com/pages/downloads/#jdk-17-lts).
|
||||
|
||||
**Node JS:**
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 512 KiB |
@@ -109,3 +109,13 @@ Clients may publish statistics to NetworkTables. If they do, they shall publish
|
||||
| rtt2_us | Integer | The time in us from last complete (ping transmission to pong reception) |
|
||||
|
||||
PhotonVision has chosen to publish to the sub-table `/photonvision/.timesync/{DEVICE_HOSTNAME}`. Future implementations of this protocol may decide to implement this as a structured data type.
|
||||
|
||||
## Wireshark Dissector
|
||||
|
||||

|
||||
|
||||
A [WireShark dissector](https://raw.githubusercontent.com/PhotonVision/photonvision/refs/heads/main/devTools/photon.lua) created for Wireshark ~=4.6 can be used to inspect Time Syncronization messages. Copy the dissector to your Wireshark plugin directory (for me, that's `C:\Users\Me\AppData\Roaming\Wireshark\plugins`), and open the capture. Because TSP uses UDP Unicast, data must be collected on the coprocessor or robot processor using a command similar to:
|
||||
|
||||
```
|
||||
sudo tcpdump -i any port 5810 -w tsp_capture.pcap
|
||||
```
|
||||
|
||||
@@ -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::
|
||||
|
||||
@@ -8,7 +8,7 @@ By default, PhotonVision attempts to make minimal assumptions of the hardware it
|
||||
|
||||
## LED Support
|
||||
|
||||
For Raspberry-Pi based hardware, PhotonVision can use [PiGPIO](https://abyz.me.uk/rpi/pigpio/) to control IO pins. The mapping of which pins control which LED's is part of the hardware config. The pins are active-high: set high when LED's are commanded on, and set low when commanded off.
|
||||
When running on Linux, PhotonVision can use [diozero](https://www.diozero.com) to control IO pins. The mapping of which pins control which LED's is part of the hardware config. The illumination LED pins are active-high: set high when LED's are commanded on, and set low when commanded off.
|
||||
|
||||
```{eval-rst}
|
||||
.. tab-set-code::
|
||||
@@ -16,14 +16,11 @@ For Raspberry-Pi based hardware, PhotonVision can use [PiGPIO](https://abyz.me.u
|
||||
|
||||
{
|
||||
"ledPins" : [ 13 ],
|
||||
"ledSetCommand" : "",
|
||||
"ledsCanDim" : true,
|
||||
"ledPWMRange" : [ 0, 100 ],
|
||||
"ledPWMSetRange" : "",
|
||||
"ledBrightnessRange" : [ 0, 100 ],
|
||||
"ledPWMFrequency" : 0,
|
||||
"ledDimCommand" : "",
|
||||
"ledBlinkCommand" : "",
|
||||
"statusRGBPins" : [ ],
|
||||
"statusRGBActiveHigh" : false,
|
||||
}
|
||||
```
|
||||
|
||||
@@ -31,29 +28,66 @@ For Raspberry-Pi based hardware, PhotonVision can use [PiGPIO](https://abyz.me.u
|
||||
No hardware boards with status RGB LED pins or non-dimming LED's have been tested yet. Please reach out to the development team if these features are desired, they can assist with configuration and testing.
|
||||
:::
|
||||
|
||||
### GPIO Pinout
|
||||
|
||||
::::{tab-set}
|
||||
|
||||
:::{tab-item} Raspberry Pi
|
||||
|
||||
The following diagram shows the GPIO pin numbering of the 40-pin header on Raspberry Pi hardware, courtesy of [pinout.xyz](https://pinout.xyz). Compute modules use the pin numbering from their respective datasheet.
|
||||
|
||||
```{image} https://raw.githubusercontent.com/pinout-xyz/Pinout.xyz/master/resources/raspberry-pi-pinout.png
|
||||
:alt: Raspberry Pi GPIO Pinout
|
||||
```
|
||||
|
||||
:::
|
||||
::::
|
||||
|
||||
### Custom GPIO
|
||||
|
||||
If your hardware does not support diozero's default provider, custom commands can be provided to interact with the GPIO lines. The examples below show what parameters are provided to each command, which can be used in any order or multiple times as needed.
|
||||
|
||||
```{eval-rst}
|
||||
.. tab-set-code::
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"getGPIOCommand" : "getGPIO {p}",
|
||||
"setGPIOCommand" : "setGPIO {p} {s}",
|
||||
"setPWMCommand" : "setPWM {p} {v}",
|
||||
"setPWMFrequencyCommand" : "setPWMFrequency {p} {f}",
|
||||
"releaseGPIOCommand" : "releseGPIO {p}",
|
||||
}
|
||||
```
|
||||
|
||||
The following template strings are used to input parameters to the commands:
|
||||
|
||||
| Template | Parameter | Values |
|
||||
| -------- | ---------- | ---------- |
|
||||
| `{p}` | pin number | integers |
|
||||
| `{s}` | state | true/false |
|
||||
| `{v}` | value | 0.0-1.0 |
|
||||
| `{f}` | frequency | integers |
|
||||
|
||||
If you were using custom LED commands from 2025 or earlier and still need custom GPIO commands, they can likely be copied over. `ledSetCommand` can be reused as `setGPIOCommand`. `ledDimCommand` can be reused with edits as `setPWMCommand`, replacing any occurrences of `{v}` with `$(awk 'BEGIN{ print int({v}*100) }')` if your command requires integer percentages.
|
||||
|
||||
## 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
|
||||
@@ -101,20 +135,16 @@ Here is a complete example `hardwareConfig.json`:
|
||||
"deviceLogoPath" : "",
|
||||
"supportURL" : "https://www.youtube.com/watch?v=b-CvLWbfZhU",
|
||||
"ledPins" : [2, 13],
|
||||
"ledSetCommand" : "",
|
||||
"ledsCanDim" : true,
|
||||
"ledPWMRange" : [ 0, 100 ],
|
||||
"ledPWMSetRange" : "",
|
||||
"ledBrightnessRange" : [ 0, 100 ],
|
||||
"ledPWMFrequency" : 0,
|
||||
"ledDimCommand" : "",
|
||||
"ledBlinkCommand" : "",
|
||||
"statusRGBPins" : [ ],
|
||||
"cpuTempCommand" : "",
|
||||
"cpuMemoryCommand" : "",
|
||||
"cpuUtilCommand" : "",
|
||||
"gpuMemoryCommand" : "",
|
||||
"gpuTempCommand" : "",
|
||||
"ramUtilCommand" : "",
|
||||
"statusRGBActiveHigh" : false,
|
||||
"getGPIOCommand" : "getGPIO {p}",
|
||||
"setGPIOCommand" : "setGPIO {p} {s}",
|
||||
"setPWMCommand" : "setPWM {p} {v}",
|
||||
"setPWMFrequencyCommand" : "setPWMFrequency {p} {f}",
|
||||
"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
|
||||
|
||||
@@ -8,7 +8,7 @@ If you’re not using cameras in 3D mode, calibration is optional, but it can st
|
||||
|
||||
## Print the Calibration Target
|
||||
|
||||
- Downloaded from our [demo site](http://photonvision.global/#/cameras), or directly from your coprocessors cameras tab.
|
||||
- Downloaded from our [demo site](https://demo.photonvision.org/#/cameras), or directly from your coprocessors cameras tab.
|
||||
- Use the ChArUco calibration board:
|
||||
- Board Type: ChAruCo
|
||||
- Tag Family: 4x4
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -11,8 +11,8 @@ A few issues make up the majority of support requests. Run through this checklis
|
||||
- Even if there's a switch between your laptop and coprocessor, you'll still want a radio or router in the loop somehow.
|
||||
- The FRC radio is the _only_ router we will officially support due to the innumerable variations between routers.
|
||||
- (Raspberry Pi, Orange Pi & Limelight only) have you flashed the correct image, and is it [up to date](https://github.com/PhotonVision/photonvision/releases/latest)?
|
||||
- Is your robot code using a **2025** version of WPILib, and is your coprocessor using the most up to date **2025** release?
|
||||
- 2022, 2023, 2024, and 2025 versions of either cannot be mix-and-matched!
|
||||
- Is your robot code using a **2026** version of WPILib, and is your coprocessor using the most up to date **2026** release?
|
||||
- 2022, 2023, 2024, 2025, and 2026 versions of either cannot be mix-and-matched!
|
||||
- Your PhotonVision version can be checked on the settings tab.
|
||||
- Is your team number correctly set on the settings tab?
|
||||
|
||||
@@ -30,7 +30,7 @@ Please check that:
|
||||
1\. You don't have the NetworkTables Server on (toggleable in the settings tab). Turn this off when doing work on a robot.
|
||||
2\. You have your team number set properly in the settings tab.
|
||||
3\. Your camera name in the `PhotonCamera` constructor matches the name in the UI.
|
||||
4\. You are using the 2025 version of WPILib and RoboRIO image.
|
||||
4\. You are using the 2026 version of WPILib and RoboRIO image.
|
||||
5\. Your robot is on.
|
||||
|
||||
If all of the above are met and you still have issues, feel free to {ref}`contact us <index:contact us>` and provide the following information:
|
||||
|
||||
@@ -138,7 +138,5 @@ docs/contributing/index
|
||||
|
||||
Java <https://javadocs.photonvision.org>
|
||||
|
||||
C++ <https://cppdocs.photonvision.org>
|
||||
|
||||
Python <https://pydocs.photonvision.org>
|
||||
C++ <https://cppdocs.photonvision.org/>
|
||||
```
|
||||
|
||||
8
photon-client/.gitignore
vendored
8
photon-client/.gitignore
vendored
@@ -1,8 +0,0 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
components.d.ts
|
||||
@@ -8,7 +8,7 @@ export default defineConfigWithVueTs(
|
||||
vueTsConfigs.recommended,
|
||||
skipFormattingConfig,
|
||||
{
|
||||
ignores: ["**/dist/**"]
|
||||
ignores: ["**/dist/**", "playwright-report"]
|
||||
},
|
||||
{
|
||||
//extends: ["js/recommended"],
|
||||
|
||||
@@ -9,15 +9,20 @@
|
||||
"build": "vite build",
|
||||
"build-demo": "vite build --mode demo",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||
"format": "prettier --write src/",
|
||||
"format": "prettier --write src/ tests/",
|
||||
"lint-ci": "eslint . --max-warnings 0 --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
|
||||
"format-ci": "prettier --check src/"
|
||||
"format-ci": "prettier --check src/",
|
||||
"test": "playwright test",
|
||||
"test-ui": "playwright test --ui",
|
||||
"test-setup": "playwright install --with-deps"
|
||||
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/prompt": "^5.2.6",
|
||||
"@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",
|
||||
@@ -28,6 +33,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.31.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@types/node": "^22.15.14",
|
||||
"@types/three": "^0.178.0",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
|
||||
83
photon-client/playwright.config.ts
Normal file
83
photon-client/playwright.config.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// import path from 'path';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/global-setup",
|
||||
testDir: "./tests",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('')`. */
|
||||
baseURL: "http://localhost:5800",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry"
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] }
|
||||
},
|
||||
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] }
|
||||
},
|
||||
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] }
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
{
|
||||
name: "Microsoft Edge",
|
||||
use: { ...devices["Desktop Edge"], channel: "msedge" }
|
||||
}
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: process.platform == "win32" ? "" : "./" + "gradlew run",
|
||||
url: "http://localhost:5800",
|
||||
timeout: 300 * 1000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
cwd: path.normalize("../")
|
||||
}
|
||||
});
|
||||
65
photon-client/pnpm-lock.yaml
generated
65
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
|
||||
@@ -45,6 +48,9 @@ importers:
|
||||
'@eslint/js':
|
||||
specifier: ^9.31.0
|
||||
version: 9.31.0
|
||||
'@playwright/test':
|
||||
specifier: ^1.56.1
|
||||
version: 1.56.1
|
||||
'@types/node':
|
||||
specifier: ^22.15.14
|
||||
version: 22.15.14
|
||||
@@ -430,6 +436,11 @@ packages:
|
||||
resolution: {integrity: sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==}
|
||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||
|
||||
'@playwright/test@1.56.1':
|
||||
resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.19':
|
||||
resolution: {integrity: sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==}
|
||||
|
||||
@@ -840,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'}
|
||||
@@ -1019,6 +1033,11 @@ packages:
|
||||
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -1247,6 +1266,16 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
playwright-core@1.56.1:
|
||||
resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.56.1:
|
||||
resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
postcss-selector-parser@6.1.2:
|
||||
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -1385,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==}
|
||||
|
||||
@@ -1525,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': {}
|
||||
@@ -1759,6 +1794,10 @@ snapshots:
|
||||
|
||||
'@pkgr/core@0.2.4': {}
|
||||
|
||||
'@playwright/test@1.56.1':
|
||||
dependencies:
|
||||
playwright: 1.56.1
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.19': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.40.2':
|
||||
@@ -2035,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)
|
||||
@@ -2188,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: {}
|
||||
@@ -2399,6 +2443,9 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
mime-types: 2.1.35
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
@@ -2605,6 +2652,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.8.3
|
||||
|
||||
playwright-core@1.56.1: {}
|
||||
|
||||
playwright@1.56.1:
|
||||
dependencies:
|
||||
playwright-core: 1.56.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
postcss-selector-parser@6.1.2:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
@@ -2746,6 +2801,8 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.8.3
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
@@ -2781,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)
|
||||
@@ -2851,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>
|
||||
@@ -25,8 +25,7 @@ const renderCompact = computed<boolean>(() => compact.value || !mdAndUp.value);
|
||||
<template>
|
||||
<v-navigation-drawer permanent :rail="renderCompact" color="sidebar">
|
||||
<v-list nav color="primary">
|
||||
<!-- List item for the heading; note that there are some tricks in setting padding and image width make things look right -->
|
||||
<v-list-item :class="renderCompact ? 'pr-0 pl-0' : ''" style="display: flex; justify-content: center">
|
||||
<v-list-item class="pr-0 pl-0" style="display: flex; justify-content: center">
|
||||
<template #prepend>
|
||||
<img v-if="!renderCompact" class="logo" src="@/assets/images/logoLarge.svg" alt="large logo" />
|
||||
<img v-else class="logo" src="@/assets/images/logoSmallTransparent.svg" alt="small logo" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -177,7 +177,7 @@ const wrappedCameras = computed<SelectItem[]>(() =>
|
||||
<v-btn
|
||||
block
|
||||
size="small"
|
||||
color="primary"
|
||||
color="buttonActive"
|
||||
:disabled="!settingsHaveChanged()"
|
||||
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
|
||||
@click="saveCameraSettings"
|
||||
|
||||
@@ -27,7 +27,13 @@ function debounce(func: (...args: any[]) => void, wait: number) {
|
||||
}
|
||||
|
||||
const debouncedEmit = debounce((v: number) => {
|
||||
emit("update:modelValue", v);
|
||||
if (v < props.min) {
|
||||
emit("update:modelValue", props.min);
|
||||
} else if (v > props.max) {
|
||||
emit("update:modelValue", props.max);
|
||||
} else {
|
||||
emit("update:modelValue", v);
|
||||
}
|
||||
}, 20);
|
||||
|
||||
const localValue = computed({
|
||||
|
||||
@@ -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",
|
||||
@@ -86,7 +88,7 @@ const renameModel = async (model: ObjectDetectionModelProperties, newName: strin
|
||||
});
|
||||
|
||||
axiosPost("/objectdetection/rename", "rename an object detection model", {
|
||||
modelPath: model.modelPath.replace("file:", ""),
|
||||
modelPath: model.modelPath,
|
||||
newName: newName
|
||||
});
|
||||
showRenameDialog.value.show = false;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -224,6 +228,7 @@ const handleBulkImport = () => {
|
||||
v-model="importVersion"
|
||||
variant="underlined"
|
||||
label="Model Version"
|
||||
data-testid="import-version-select"
|
||||
:items="
|
||||
useSettingsStore().general.supportedBackends?.includes('RKNN')
|
||||
? ['YOLOv5', 'YOLOv8', 'YOLO11']
|
||||
@@ -324,7 +329,7 @@ const handleBulkImport = () => {
|
||||
<th>Info</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody data-testid="model-table">
|
||||
<tr v-for="model in supportedModels" :key="model.modelPath">
|
||||
<td>{{ model.nickname }}</td>
|
||||
<td>{{ model.labels.join(", ") }}</td>
|
||||
@@ -417,7 +422,7 @@ const handleBulkImport = () => {
|
||||
<a
|
||||
ref="exportIndividualModel"
|
||||
style="color: black; text-decoration: none; display: none"
|
||||
:href="`http://${address}/api/objectdetection/exportIndividual?modelPath=${showInfo.model.modelPath.replace('file:', '')}`"
|
||||
:href="`http://${address}/api/objectdetection/exportIndividual?modelPath=${showInfo.model.modelPath}`"
|
||||
:download="`${showInfo.model.nickname}_${showInfo.model.family}_${showInfo.model.version}_${showInfo.model.resolutionWidth}x${showInfo.model.resolutionHeight}_${showInfo.model.labels.join('_')}.${showInfo.model.family.toLowerCase()}`"
|
||||
target="_blank"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,11 +52,12 @@ export const restoreThemeConfig = (theme: ThemeInstance) => {
|
||||
: (customSurface ?? defaultTheme.colors!.sidebar!);
|
||||
|
||||
theme.themes.value[theme.global.name.value].colors.primary = customPrimary ?? defaultTheme.colors!.primary!;
|
||||
theme.themes.value[theme.global.name.value].colors.buttonActive = customPrimary ?? defaultTheme.colors!.buttonActive!;
|
||||
theme.themes.value[theme.global.name.value].colors.buttonActive =
|
||||
(themeType === "light" ? customPrimary : customSecondary) ?? defaultTheme.colors!.buttonActive!;
|
||||
|
||||
theme.themes.value[theme.global.name.value].colors.secondary = customSecondary ?? defaultTheme.colors!.secondary!;
|
||||
theme.themes.value[theme.global.name.value].colors.buttonPassive =
|
||||
customSecondary ?? defaultTheme.colors!.buttonPassive!;
|
||||
(themeType === "light" ? customSecondary : customPrimary) ?? defaultTheme.colors!.buttonPassive!;
|
||||
|
||||
theme.themes.value[theme.global.name.value].colors.accent = customSecondary ?? defaultTheme.colors!.accent!;
|
||||
theme.themes.value[theme.global.name.value].colors.toggle = customSecondary ?? defaultTheme.colors!.toggle!;
|
||||
|
||||
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: {
|
||||
|
||||
@@ -27,10 +27,7 @@ export const useCameraSettingsStore = defineStore("cameraSettings", {
|
||||
}),
|
||||
getters: {
|
||||
needsCameraConfiguration(): boolean {
|
||||
return (
|
||||
JSON.stringify(useCameraSettingsStore().cameras[PlaceholderCameraSettings.uniqueName]) ===
|
||||
JSON.stringify(PlaceholderCameraSettings)
|
||||
);
|
||||
return useCameraSettingsStore().cameras["Placeholder Name"] === PlaceholderCameraSettings;
|
||||
},
|
||||
// TODO update types to update this value being undefined. This would be a decently large change.
|
||||
currentCameraSettings(): UiCameraConfiguration {
|
||||
@@ -94,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;
|
||||
},
|
||||
@@ -144,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
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ActivePipelineSettings, DefaultAprilTagPipelineSettings } from "@/types/PipelineTypes";
|
||||
import type { Pose3d } from "@/types/PhotonTrackingTypes";
|
||||
import type { WebsocketCameraSettingsUpdate } from "./WebsocketDataTypes";
|
||||
import { reactive } from "vue";
|
||||
|
||||
export interface GeneralSettings {
|
||||
version?: string;
|
||||
@@ -33,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 {
|
||||
@@ -190,7 +194,7 @@ export interface BoardObservation {
|
||||
locationInImageSpace: CvPoint[];
|
||||
reprojectionErrors: CvPoint[];
|
||||
optimisedCameraToObject: Pose3d;
|
||||
includeObservationInCalibration: boolean;
|
||||
cornersUsed: boolean[];
|
||||
snapshotName: string;
|
||||
snapshotData: JsonImageMat;
|
||||
}
|
||||
@@ -201,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 {
|
||||
@@ -263,6 +273,8 @@ export interface UiCameraConfiguration {
|
||||
minWhiteBalanceTemp: number;
|
||||
maxWhiteBalanceTemp: number;
|
||||
|
||||
fpsLimit: number;
|
||||
|
||||
matchedCameraInfo: PVCameraInfo;
|
||||
isConnected: boolean;
|
||||
hasConnected: boolean;
|
||||
@@ -274,7 +286,7 @@ export interface CameraSettingsChangeRequest {
|
||||
quirksToChange: Record<ValidQuirks, boolean>;
|
||||
}
|
||||
|
||||
export const PlaceholderCameraSettings: UiCameraConfiguration = {
|
||||
export const PlaceholderCameraSettings: UiCameraConfiguration = reactive({
|
||||
cameraPath: "/dev/null",
|
||||
|
||||
nickname: "Placeholder Camera",
|
||||
@@ -314,7 +326,7 @@ export const PlaceholderCameraSettings: UiCameraConfiguration = {
|
||||
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,
|
||||
@@ -324,28 +336,72 @@ export const PlaceholderCameraSettings: UiCameraConfiguration = {
|
||||
},
|
||||
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"],
|
||||
@@ -388,10 +444,11 @@ export const PlaceholderCameraSettings: UiCameraConfiguration = {
|
||||
PVCSICameraInfo: undefined,
|
||||
PVUsbCameraInfo: undefined
|
||||
},
|
||||
fpsLimit: -1,
|
||||
isConnected: true,
|
||||
hasConnected: true,
|
||||
mismatch: false
|
||||
};
|
||||
});
|
||||
|
||||
export enum CalibrationBoardTypes {
|
||||
Chessboard = 0,
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface WebsocketCameraSettingsUpdate {
|
||||
minWhiteBalanceTemp: number;
|
||||
maxWhiteBalanceTemp: number;
|
||||
matchedCameraInfo: PVCameraInfo;
|
||||
fpsLimit: number;
|
||||
isConnected: boolean;
|
||||
hasConnected: boolean;
|
||||
mismatch: boolean;
|
||||
|
||||
@@ -87,7 +87,7 @@ const unmatchedCameras = computed(() => {
|
||||
const activeVisionModules = computed(() =>
|
||||
Object.values(useCameraSettingsStore().cameras)
|
||||
// Ignore placeholder camera
|
||||
.filter((camera) => JSON.stringify(camera) !== JSON.stringify(PlaceholderCameraSettings))
|
||||
.filter((camera) => camera !== PlaceholderCameraSettings)
|
||||
// Display connected cameras first
|
||||
.sort(
|
||||
(first, second) =>
|
||||
|
||||
@@ -64,10 +64,8 @@ const cameraMismatchWarningShown = computed<boolean>(() => {
|
||||
return (
|
||||
Object.values(useCameraSettingsStore().cameras)
|
||||
// Ignore placeholder camera
|
||||
.filter((camera) => JSON.stringify(camera) !== JSON.stringify(PlaceholderCameraSettings))
|
||||
.some((camera) => {
|
||||
return camera.mismatch;
|
||||
})
|
||||
.filter((camera) => camera !== PlaceholderCameraSettings)
|
||||
.some((camera) => camera.mismatch)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -79,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>
|
||||
|
||||
@@ -108,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"
|
||||
@@ -148,6 +163,7 @@ const showCameraSetupDialog = ref(useCameraSettingsStore().needsCameraConfigurat
|
||||
<PipelineConfigCard />
|
||||
|
||||
<!-- TODO - not sure this belongs here -->
|
||||
<!-- Need v-model to allow the dialog to be dismissed and v-if to only display when cameras need configuration -->
|
||||
<v-dialog
|
||||
v-if="useCameraSettingsStore().needsCameraConfiguration"
|
||||
v-model="showCameraSetupDialog"
|
||||
|
||||
@@ -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 NetworkingCard from "@/components/settings/NetworkingCard.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 />
|
||||
<NetworkingCard />
|
||||
<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>
|
||||
|
||||
16
photon-client/tests/fixtures.ts
Normal file
16
photon-client/tests/fixtures.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { test as base } from "@playwright/test";
|
||||
import axios from "axios";
|
||||
|
||||
export const test = base.extend({
|
||||
page: async ({ page }, use) => {
|
||||
// Use the page in the test (no per-test backend reset here)
|
||||
axios.defaults.baseURL = "http://localhost:5800/api/test";
|
||||
await use(page);
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeAll(async () => {
|
||||
console.log("Running before all tests: Resetting backend state...");
|
||||
await axios.post("http://localhost:5800/api/test/resetBackend");
|
||||
await axios.post("http://localhost:5800/api/test/activateTestMode");
|
||||
});
|
||||
7
photon-client/tests/global-setup.ts
Normal file
7
photon-client/tests/global-setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
async function globalSetup() {
|
||||
// You can perform global setup tasks here, such as starting a server or setting environment variables
|
||||
const path = await import("path");
|
||||
process.env.TESTS_DIR = path.resolve(process.cwd());
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
39
photon-client/tests/input.spec.ts
Normal file
39
photon-client/tests/input.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures.ts";
|
||||
|
||||
test("Camera Gain Slider won't go past max or min", async ({ page }) => {
|
||||
await page.goto("http://localhost:5800/#/dashboard");
|
||||
await page.locator("div").filter({ hasText: "Set up some cameras to get started!" }).nth(2).press("Escape");
|
||||
|
||||
// Fill in Camera Gain text field with 1000
|
||||
await page.locator("#input-v-44").fill("1000");
|
||||
await page.locator("#input-v-44").press("Enter");
|
||||
await expect(page.locator("#input-v-44")).toHaveValue("100");
|
||||
|
||||
// Try using buttons to go past the max
|
||||
await page.getByRole("button", { name: "appended action" }).nth(2).click();
|
||||
await expect(page.locator("#input-v-44")).toHaveValue("100");
|
||||
|
||||
// Make sure the value is actually properly limited, not just visually
|
||||
await page.getByRole("button", { name: "prepended action" }).nth(2).click();
|
||||
await expect(page.locator("#input-v-44")).toHaveValue("99");
|
||||
|
||||
await page.locator("#input-v-44").fill("-10");
|
||||
await page.locator("#input-v-44").press("Enter");
|
||||
await expect(page.locator("#input-v-44")).toHaveValue("0");
|
||||
|
||||
await page.getByRole("button", { name: "prepended action" }).nth(2).click();
|
||||
await expect(page.locator("#input-v-44")).toHaveValue("0");
|
||||
|
||||
// Make sure the value is actually properly limited, not just visually
|
||||
await page.getByRole("button", { name: "appended action" }).nth(2).click();
|
||||
await expect(page.locator("#input-v-44")).toHaveValue("1");
|
||||
|
||||
// Make sure that the guard actually prevents value setting, instead of just reverting the value
|
||||
// This can be ensured by making sure the Camera Gain field doesn't disappear (disappears when the value is -1)
|
||||
await page.getByRole("button", { name: "prepended action" }).nth(2).click();
|
||||
await page.getByRole("button", { name: "prepended action" }).nth(2).click();
|
||||
await expect(page.locator("#input-v-44")).toHaveValue("0");
|
||||
|
||||
await expect(page.getByText("Camera Gain", { exact: true })).toBeVisible();
|
||||
});
|
||||
79
photon-client/tests/platformDependent/od.spec.ts
Normal file
79
photon-client/tests/platformDependent/od.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../fixtures";
|
||||
import axios from "axios";
|
||||
import path from "path";
|
||||
|
||||
const fakeModelName = "FAKE-MODEL";
|
||||
const fakeLabels = "test, 1, woof";
|
||||
const newModelName = "foo-bar";
|
||||
const platforms = ["LINUX_RK3588_64", "LINUX_QCS6490"];
|
||||
|
||||
for (const platform of platforms) {
|
||||
test.describe(`Platform: ${platform}`, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/#/settings");
|
||||
await axios.post("/override/platform", { platform: platform });
|
||||
await page.reload();
|
||||
});
|
||||
|
||||
test("testSettingsPage", async ({ page }) => {
|
||||
if (platform.endsWith("RK3588_64")) {
|
||||
await expect(page.getByRole("main")).toContainText("Linux AARCH 64-bit with RK3588");
|
||||
} else if (platform.endsWith("QCS6490")) {
|
||||
await expect(page.getByRole("main")).toContainText("Linux AARCH 64-bit with QCS6490");
|
||||
}
|
||||
await expect(page.getByText("Object Detection")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Upload model", async ({ page }) => {
|
||||
const testsDir = process.env.TESTS_DIR;
|
||||
if (!testsDir) {
|
||||
throw new Error("TESTS_DIR is not set");
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: "Import Model" }).click();
|
||||
await page.getByRole("textbox", { name: "Labels" }).fill(fakeLabels);
|
||||
await page.getByRole("spinbutton", { name: "Width" }).fill("640");
|
||||
await page.getByRole("spinbutton", { name: "Height" }).fill("640");
|
||||
await page.getByTestId("import-version-select").click();
|
||||
await page.getByRole("option", { name: "YOLOv8" }).click();
|
||||
|
||||
const modelFile = platform.endsWith("RK3588_64") ? `${fakeModelName}.rknn` : `${fakeModelName}.tflite`;
|
||||
await page
|
||||
.getByRole("button", { name: "Model File Model File" })
|
||||
.setInputFiles(path.join(testsDir, "tests/resources", modelFile));
|
||||
|
||||
await page.getByRole("button", { name: "Import Object Detection Model" }).click();
|
||||
|
||||
await page.goto("/#/settings");
|
||||
const tableRow = page.getByTestId("model-table").locator("tr", { hasText: fakeModelName });
|
||||
|
||||
await expect(tableRow).toBeVisible();
|
||||
await expect(tableRow).toContainText(fakeLabels);
|
||||
});
|
||||
|
||||
test("Rename model", async ({ page }) => {
|
||||
const tableRow = page.getByTestId("model-table").locator("tr", { hasText: fakeModelName });
|
||||
|
||||
await tableRow.getByRole("button", { name: "Rename Model" }).click();
|
||||
await page.getByRole("textbox", { name: "New Name New Name" }).fill(newModelName);
|
||||
await page.getByRole("button", { name: "Rename", exact: true }).click();
|
||||
|
||||
await page.reload();
|
||||
|
||||
const renamedRow = page.getByTestId("model-table").locator("tr", { hasText: newModelName });
|
||||
await expect(renamedRow).toContainText(fakeLabels);
|
||||
});
|
||||
|
||||
test("Delete model", async ({ page }) => {
|
||||
const tableRow = page.getByTestId("model-table").locator("tr", { hasText: newModelName });
|
||||
|
||||
await tableRow.getByRole("button", { name: "Delete Model" }).click();
|
||||
await page.getByRole("button", { name: "Delete model", exact: true }).click();
|
||||
|
||||
await page.reload();
|
||||
const deletedRow = page.getByTestId("model-table").locator("tr", { hasText: newModelName });
|
||||
await expect(deletedRow).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
0
photon-client/tests/resources/FAKE-MODEL.tflite
Normal file
0
photon-client/tests/resources/FAKE-MODEL.tflite
Normal file
@@ -1,4 +1,4 @@
|
||||
apply plugin: 'edu.wpi.first.WpilibTools'
|
||||
apply plugin: 'org.photonvision.tools.WpilibTools'
|
||||
|
||||
import java.nio.file.Path
|
||||
|
||||
@@ -28,37 +28,39 @@ dependencies {
|
||||
wpilibNatives wpilibTools.deps.wpilib("hal")
|
||||
wpilibNatives wpilibTools.deps.wpilibOpenCv("frc" + openCVYear, wpi.versions.opencvVersion.get())
|
||||
|
||||
// Zip
|
||||
// These stay as implementation dependencies since they don't have native code that gets packaged
|
||||
implementation 'org.zeroturnaround:zt-zip:1.14'
|
||||
|
||||
implementation "org.xerial:sqlite-jdbc:3.41.0.0"
|
||||
implementation("org.photonvision:rknn_jni-jni:$rknnVersion:linuxarm64") {
|
||||
transitive = false
|
||||
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:$jniPlatform") {
|
||||
transitive = false
|
||||
}
|
||||
wpilibNatives("org.photonvision:rubik_jni-jni:$rubikVersion:$jniPlatform") {
|
||||
transitive = false
|
||||
}
|
||||
wpilibNatives("org.photonvision:photon-libcamera-gl-driver-jni:$libcameraDriverVersion:$jniPlatform") {
|
||||
transitive = false
|
||||
}
|
||||
}
|
||||
|
||||
implementation("org.photonvision:rknn_jni-java:$rknnVersion") {
|
||||
transitive = false
|
||||
}
|
||||
implementation("org.photonvision:rubik_jni-jni:$rubikVersion:linuxarm64") {
|
||||
transitive = false
|
||||
}
|
||||
|
||||
implementation("org.photonvision:rubik_jni-java:$rubikVersion") {
|
||||
transitive = false
|
||||
}
|
||||
implementation("org.photonvision:photon-libcamera-gl-driver-jni:$libcameraDriverVersion:linuxarm64") {
|
||||
transitive = false
|
||||
}
|
||||
|
||||
implementation "org.photonvision:photon-libcamera-gl-driver-java:$libcameraDriverVersion"
|
||||
|
||||
implementation "org.photonvision:photon-mrcal-java:$mrcalVersion"
|
||||
|
||||
// Only include mrcal natives on platforms that we build for
|
||||
if (!(jniPlatform in [
|
||||
"osxx86-64",
|
||||
"osxarm64"
|
||||
])) {
|
||||
implementation("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'
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import edu.wpi.first.util.CombinedRuntimeLoader;
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import org.photonvision.jni.LibraryLoader;
|
||||
|
||||
public class LoadJNI {
|
||||
private static HashMap<JNITypes, Boolean> loadedMap = new HashMap<>();
|
||||
|
||||
public enum JNITypes {
|
||||
RUBIK_DETECTOR("tensorflowlite", "tensorflowlite_c", "external_delegate", "rubik_jni"),
|
||||
RKNN_DETECTOR("rga", "rknnrt", "rknn_jni"),
|
||||
MRCAL("mrcal_jni"),
|
||||
LIBCAMERA("photonlibcamera");
|
||||
|
||||
public final String[] libraries;
|
||||
|
||||
JNITypes(String... libraries) {
|
||||
this.libraries = libraries;
|
||||
}
|
||||
}
|
||||
|
||||
public static synchronized void forceLoad(JNITypes type) throws IOException {
|
||||
loadLibraries();
|
||||
|
||||
if (loadedMap.getOrDefault(type, false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
CombinedRuntimeLoader.loadLibraries(LoadJNI.class, type.libraries);
|
||||
loadedMap.put(type, true);
|
||||
}
|
||||
|
||||
public static boolean loadLibraries() {
|
||||
return LibraryLoader.loadWpiLibraries() && LibraryLoader.loadTargeting();
|
||||
}
|
||||
|
||||
public static boolean hasLoaded(JNITypes t) {
|
||||
return loadedMap.getOrDefault(t, false);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -28,23 +28,18 @@ public class HardwareConfig {
|
||||
|
||||
// LED control
|
||||
public final ArrayList<Integer> ledPins;
|
||||
public final String ledSetCommand;
|
||||
public final boolean ledsCanDim;
|
||||
public final ArrayList<Integer> ledBrightnessRange;
|
||||
public final String ledDimCommand;
|
||||
public final String ledBlinkCommand;
|
||||
public final int ledPWMFrequency;
|
||||
public final ArrayList<Integer> statusRGBPins;
|
||||
public final boolean statusRGBActiveHigh;
|
||||
|
||||
// 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;
|
||||
// Custom GPIO
|
||||
public final String getGPIOCommand;
|
||||
public final String setGPIOCommand;
|
||||
public final String setPWMCommand;
|
||||
public final String setPWMFrequencyCommand;
|
||||
public final String releaseGPIOCommand;
|
||||
|
||||
// Device stuff
|
||||
public final String restartHardwareCommand;
|
||||
@@ -55,42 +50,32 @@ public class HardwareConfig {
|
||||
String deviceLogoPath,
|
||||
String supportURL,
|
||||
ArrayList<Integer> ledPins,
|
||||
String ledSetCommand,
|
||||
boolean ledsCanDim,
|
||||
ArrayList<Integer> ledBrightnessRange,
|
||||
String ledDimCommand,
|
||||
String ledBlinkCommand,
|
||||
int ledPwmFrequency,
|
||||
ArrayList<Integer> statusRGBPins,
|
||||
String cpuTempCommand,
|
||||
String cpuMemoryCommand,
|
||||
String cpuUtilCommand,
|
||||
String cpuThrottleReasonCmd,
|
||||
String cpuUptimeCommand,
|
||||
String gpuMemoryCommand,
|
||||
String ramUtilCommand,
|
||||
String gpuMemUsageCommand,
|
||||
String diskUsageCommand,
|
||||
boolean statusRGBActiveHigh,
|
||||
String getGPIOCommand,
|
||||
String setGPIOCommand,
|
||||
String setPWMCommand,
|
||||
String setPWMFrequencyCommand,
|
||||
String releaseGPIOCommand,
|
||||
String restartHardwareCommand,
|
||||
double vendorFOV) {
|
||||
this.deviceName = deviceName;
|
||||
this.deviceLogoPath = deviceLogoPath;
|
||||
this.supportURL = supportURL;
|
||||
this.ledPins = ledPins;
|
||||
this.ledSetCommand = ledSetCommand;
|
||||
this.ledsCanDim = ledsCanDim;
|
||||
this.ledBrightnessRange = ledBrightnessRange;
|
||||
this.ledDimCommand = ledDimCommand;
|
||||
this.ledBlinkCommand = ledBlinkCommand;
|
||||
this.ledPWMFrequency = ledPwmFrequency;
|
||||
this.statusRGBPins = statusRGBPins;
|
||||
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.statusRGBActiveHigh = statusRGBActiveHigh;
|
||||
this.getGPIOCommand = getGPIOCommand;
|
||||
this.setGPIOCommand = setGPIOCommand;
|
||||
this.setPWMCommand = setPWMCommand;
|
||||
this.setPWMFrequencyCommand = setPWMFrequencyCommand;
|
||||
this.releaseGPIOCommand = releaseGPIOCommand;
|
||||
this.restartHardwareCommand = restartHardwareCommand;
|
||||
this.vendorFOV = vendorFOV;
|
||||
}
|
||||
@@ -100,21 +85,16 @@ public class HardwareConfig {
|
||||
deviceLogoPath = "";
|
||||
supportURL = "";
|
||||
ledPins = new ArrayList<>();
|
||||
ledSetCommand = "";
|
||||
ledsCanDim = false;
|
||||
ledBrightnessRange = new ArrayList<>();
|
||||
ledDimCommand = "";
|
||||
ledBlinkCommand = "";
|
||||
ledPWMFrequency = 0;
|
||||
statusRGBPins = new ArrayList<>();
|
||||
cpuTempCommand = "";
|
||||
cpuMemoryCommand = "";
|
||||
cpuUtilCommand = "";
|
||||
cpuThrottleReasonCmd = "";
|
||||
cpuUptimeCommand = "";
|
||||
gpuMemoryCommand = "";
|
||||
ramUtilCommand = "";
|
||||
gpuMemUsageCommand = "";
|
||||
diskUsageCommand = "";
|
||||
statusRGBActiveHigh = false;
|
||||
getGPIOCommand = "";
|
||||
setGPIOCommand = "";
|
||||
setPWMCommand = "";
|
||||
setPWMFrequencyCommand = "";
|
||||
releaseGPIOCommand = "";
|
||||
restartHardwareCommand = "";
|
||||
vendorFOV = -1;
|
||||
}
|
||||
@@ -127,19 +107,14 @@ public class HardwareConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return True if any command has been configured to a non-default empty, false otherwise
|
||||
* @return True if any gpio command has been configured to be non-empty, false otherwise
|
||||
*/
|
||||
public final boolean hasCommandsConfigured() {
|
||||
return cpuTempCommand != ""
|
||||
|| cpuMemoryCommand != ""
|
||||
|| cpuUtilCommand != ""
|
||||
|| cpuThrottleReasonCmd != ""
|
||||
|| cpuUptimeCommand != ""
|
||||
|| gpuMemoryCommand != ""
|
||||
|| ramUtilCommand != ""
|
||||
|| ledBlinkCommand != ""
|
||||
|| gpuMemUsageCommand != ""
|
||||
|| diskUsageCommand != "";
|
||||
public final boolean hasGPIOCommandsConfigured() {
|
||||
return getGPIOCommand != ""
|
||||
|| setGPIOCommand != ""
|
||||
|| setPWMCommand != ""
|
||||
|| setPWMFrequencyCommand != ""
|
||||
|| releaseGPIOCommand != "";
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -152,36 +127,26 @@ public class HardwareConfig {
|
||||
+ supportURL
|
||||
+ ", ledPins="
|
||||
+ ledPins
|
||||
+ ", ledSetCommand="
|
||||
+ ledSetCommand
|
||||
+ ", ledsCanDim="
|
||||
+ ledsCanDim
|
||||
+ ", ledBrightnessRange="
|
||||
+ ledBrightnessRange
|
||||
+ ", ledDimCommand="
|
||||
+ ledDimCommand
|
||||
+ ", ledBlinkCommand="
|
||||
+ ledBlinkCommand
|
||||
+ ", ledPWMFrequency="
|
||||
+ ledPWMFrequency
|
||||
+ ", statusRGBPins="
|
||||
+ statusRGBPins
|
||||
+ ", cpuTempCommand="
|
||||
+ cpuTempCommand
|
||||
+ ", cpuMemoryCommand="
|
||||
+ cpuMemoryCommand
|
||||
+ ", cpuUtilCommand="
|
||||
+ cpuUtilCommand
|
||||
+ ", cpuThrottleReasonCmd="
|
||||
+ cpuThrottleReasonCmd
|
||||
+ ", cpuUptimeCommand="
|
||||
+ cpuUptimeCommand
|
||||
+ ", gpuMemoryCommand="
|
||||
+ gpuMemoryCommand
|
||||
+ ", ramUtilCommand="
|
||||
+ ramUtilCommand
|
||||
+ ", gpuMemUsageCommand="
|
||||
+ gpuMemUsageCommand
|
||||
+ ", diskUsageCommand="
|
||||
+ diskUsageCommand
|
||||
+ ", statusRGBActiveHigh"
|
||||
+ statusRGBActiveHigh
|
||||
+ ", getGPIOCommand="
|
||||
+ getGPIOCommand
|
||||
+ ", setGPIOCommand="
|
||||
+ setGPIOCommand
|
||||
+ ", setPWMCommand="
|
||||
+ setPWMCommand
|
||||
+ ", setPWMFrequencyCommand="
|
||||
+ setPWMFrequencyCommand
|
||||
+ ", releaseGPIOCommand="
|
||||
+ releaseGPIOCommand
|
||||
+ ", 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;
|
||||
}
|
||||
|
||||
@@ -212,12 +212,23 @@ public class NeuralNetworkModelManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the singleton instance of the NeuralNetworkModelManager
|
||||
* Returns the singleton instance of the NeuralNetworkModelManager. Call getInstance() to use the
|
||||
* default (no reset), or getInstance(true) to reset.
|
||||
*
|
||||
* @return The singleton instance
|
||||
*/
|
||||
public static NeuralNetworkModelManager getInstance() {
|
||||
if (INSTANCE == null) {
|
||||
return getInstance(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the singleton instance of the NeuralNetworkModelManager, optionally resetting it.
|
||||
*
|
||||
* @param reset If true, resets the instance
|
||||
* @return The singleton instance
|
||||
*/
|
||||
public static NeuralNetworkModelManager getInstance(boolean reset) {
|
||||
if (INSTANCE == null || reset) {
|
||||
INSTANCE = new NeuralNetworkModelManager();
|
||||
}
|
||||
return INSTANCE;
|
||||
@@ -261,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
|
||||
@@ -310,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 "
|
||||
@@ -401,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,162 +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.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
|
||||
*/
|
||||
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();
|
||||
|
||||
@@ -253,6 +250,8 @@ public class NetworkTablesManager {
|
||||
String mac = NetworkUtils.getMacAddress();
|
||||
if (!mac.equals(currentMacAddress)) {
|
||||
logger.debug("MAC address changed! New MAC address is " + mac + ", was " + currentMacAddress);
|
||||
kCoprocTable.getSubTable(currentMacAddress).getEntry("hostname").unpublish();
|
||||
kCoprocTable.getSubTable(currentMacAddress).getEntry("cameraNames").unpublish();
|
||||
currentMacAddress = mac;
|
||||
}
|
||||
if (mac.isEmpty()) {
|
||||
@@ -260,7 +259,13 @@ public class NetworkTablesManager {
|
||||
return;
|
||||
}
|
||||
|
||||
String hostname = ConfigManager.getInstance().getConfig().getNetworkConfig().hostname;
|
||||
var config = ConfigManager.getInstance().getConfig();
|
||||
String hostname;
|
||||
if (config.getNetworkConfig().shouldManage) {
|
||||
hostname = config.getNetworkConfig().hostname;
|
||||
} else {
|
||||
hostname = CameraServerJNI.getHostname();
|
||||
}
|
||||
if (hostname == null || hostname.isEmpty()) {
|
||||
logger.error("Cannot check hostname and camera names, hostname is not set!");
|
||||
return;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -19,14 +19,14 @@ package org.photonvision.common.dataflow.websocket;
|
||||
|
||||
import java.util.List;
|
||||
import org.photonvision.PhotonVersion;
|
||||
import org.photonvision.common.LoadJNI;
|
||||
import org.photonvision.common.LoadJNI.JNITypes;
|
||||
import org.photonvision.common.configuration.NeuralNetworkModelManager;
|
||||
import org.photonvision.common.configuration.PhotonConfiguration;
|
||||
import org.photonvision.common.dataflow.networktables.NetworkTablesManager;
|
||||
import org.photonvision.common.hardware.Platform;
|
||||
import org.photonvision.common.networking.NetworkManager;
|
||||
import org.photonvision.common.networking.NetworkUtils;
|
||||
import org.photonvision.mrcal.MrCalJNILoader;
|
||||
import org.photonvision.raspi.LibCameraJNILoader;
|
||||
import org.photonvision.vision.processes.VisionModule;
|
||||
import org.photonvision.vision.processes.VisionSourceManager;
|
||||
|
||||
@@ -53,8 +53,8 @@ public class UIPhotonConfiguration {
|
||||
new UIGeneralSettings(
|
||||
PhotonVersion.versionString,
|
||||
// TODO add support for other types of GPU accel
|
||||
LibCameraJNILoader.getInstance().isSupported() ? "Zerocopy Libcamera Working" : "",
|
||||
MrCalJNILoader.getInstance().isLoaded(),
|
||||
LoadJNI.hasLoaded(JNITypes.LIBCAMERA) ? "Zerocopy Libcamera Working" : "",
|
||||
LoadJNI.hasLoaded(JNITypes.MRCAL),
|
||||
c.neuralNetworkPropertyManager().getModels(),
|
||||
NeuralNetworkModelManager.getInstance().getSupportedBackends(),
|
||||
c.getHardwareConfig().deviceName.isEmpty()
|
||||
|
||||
@@ -1,99 +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.GPIO;
|
||||
|
||||
import org.photonvision.common.configuration.HardwareConfig;
|
||||
import org.photonvision.common.hardware.Platform;
|
||||
|
||||
public class CustomGPIO extends GPIOBase {
|
||||
private boolean currentState;
|
||||
private final int port;
|
||||
|
||||
public CustomGPIO(int port) {
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void togglePin() {
|
||||
if (this.port != -1) {
|
||||
execute(
|
||||
commands
|
||||
.get("setState")
|
||||
.replace("{s}", String.valueOf(!currentState))
|
||||
.replace("{p}", String.valueOf(this.port)));
|
||||
currentState = !currentState;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPinNumber() {
|
||||
return port;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setStateImpl(boolean state) {
|
||||
if (this.port != -1) {
|
||||
execute(
|
||||
commands
|
||||
.get("setState")
|
||||
.replace("{s}", String.valueOf(state))
|
||||
.replace("{p}", String.valueOf(port)));
|
||||
currentState = state;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shutdown() {
|
||||
if (this.port != -1) {
|
||||
execute(commands.get("shutdown"));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getStateImpl() {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void blinkImpl(int pulseTimeMillis, int blinks) {
|
||||
execute(
|
||||
commands
|
||||
.get("blink")
|
||||
.replace("{pulseTime}", String.valueOf(pulseTimeMillis))
|
||||
.replace("{blinks}", String.valueOf(blinks))
|
||||
.replace("{p}", String.valueOf(this.port)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setBrightnessImpl(int brightness) {
|
||||
execute(
|
||||
commands
|
||||
.get("dim")
|
||||
.replace("{p}", String.valueOf(port))
|
||||
.replace("{v}", String.valueOf(brightness)));
|
||||
}
|
||||
|
||||
public static void setConfig(HardwareConfig config) {
|
||||
if (Platform.isRaspberryPi()) return;
|
||||
commands.replace("setState", config.ledSetCommand);
|
||||
commands.replace("dim", config.ledDimCommand);
|
||||
commands.replace("blink", config.ledBlinkCommand);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +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.GPIO;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import org.photonvision.common.logging.LogGroup;
|
||||
import org.photonvision.common.logging.Logger;
|
||||
import org.photonvision.common.util.ShellExec;
|
||||
|
||||
public abstract class GPIOBase {
|
||||
private static final Logger logger = new Logger(GPIOBase.class, LogGroup.General);
|
||||
private static final ShellExec runCommand = new ShellExec(true, true);
|
||||
|
||||
protected static HashMap<String, String> commands =
|
||||
new HashMap<>() {
|
||||
{
|
||||
put("setState", "");
|
||||
put("shutdown", "");
|
||||
put("dim", "");
|
||||
put("blink", "");
|
||||
}
|
||||
};
|
||||
|
||||
protected static String execute(String command) {
|
||||
try {
|
||||
runCommand.executeBashCommand(command);
|
||||
} catch (Exception e) {
|
||||
logger.error(Arrays.toString(e.getStackTrace()));
|
||||
return "";
|
||||
}
|
||||
return runCommand.getOutput();
|
||||
}
|
||||
|
||||
public abstract int getPinNumber();
|
||||
|
||||
public void setState(boolean state) {
|
||||
if (getPinNumber() != -1) {
|
||||
setStateImpl(state);
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract void setStateImpl(boolean state);
|
||||
|
||||
public final void setOff() {
|
||||
setState(false);
|
||||
}
|
||||
|
||||
public final void setOn() {
|
||||
setState(true);
|
||||
}
|
||||
|
||||
public void togglePin() {
|
||||
setState(!getStateImpl());
|
||||
}
|
||||
|
||||
public abstract boolean shutdown();
|
||||
|
||||
public final boolean getState() {
|
||||
if (getPinNumber() != -1) {
|
||||
return getStateImpl();
|
||||
} else return false;
|
||||
}
|
||||
|
||||
public abstract boolean getStateImpl();
|
||||
|
||||
public final void blink(int pulseTimeMillis, int blinks) {
|
||||
if (getPinNumber() != -1) {
|
||||
blinkImpl(pulseTimeMillis, blinks);
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract void blinkImpl(int pulseTimeMillis, int blinks);
|
||||
|
||||
public final void setBrightness(int brightness) {
|
||||
if (getPinNumber() != -1) {
|
||||
if (brightness > 100) brightness = 100;
|
||||
if (brightness < 0) brightness = 0;
|
||||
setBrightnessImpl(brightness);
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract void setBrightnessImpl(int brightness);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user