Compare commits

...

22 Commits

Author SHA1 Message Date
Cameron (3539)
5762167186 Test way too many calibration points (#1585)
![image](https://github.com/user-attachments/assets/508674fe-1d5e-41bf-a115-23bcf1638da0)

Limit seems to be at -least- 700,000 corners in my testing. That's
enough for anyone, surely.

---------

Co-authored-by: Matt <matthew.morley.ca@gmail.com>
2024-11-18 02:10:40 -05:00
Matt
faa9eb0093 Disable VisionSystemSimTest entirely (#1584)
This test is still failing on main, see
https://github.com/PhotonVision/photonvision/actions/runs/11875827435/job/33093483193
2024-11-18 01:55:00 -05:00
Gold856
005363c5cd Add web resource folder back to gitignore (#1583) 2024-11-17 00:24:21 -05:00
Matt
478723ca2c Skip Vision System Sim Tests on Windows (#1581) 2024-11-16 20:09:41 -08:00
Matt
05dcfa2a13 Remove time source override (#1582) 2024-11-16 20:09:33 -08:00
Gold856
eff95c09f1 Clean up build (#1572)
Fixes #1564. Also copies vendordep JSONs to the examples as advised by
Thad. Removes unused shared/javacpp/setupBuild.gradle. Also removes
unnecessary `chmod +x gradlew` from CI workflows.
2024-11-16 21:30:34 -05:00
Lucien Morey
097e641789 [Python] remove opencv dependency for robot installations (#1580) 2024-11-16 18:26:07 -08:00
James Ward
f107c94d05 [python] Fix population of metadata (#1578) 2024-11-16 18:25:43 -08:00
Craig Schardt
93242edc86 Fix rate limiting in sphinx link checker (#1579)
Fixes rate-limit errors in the sphinx linkchecker on GitHub links caused
by a missing token.

```
-rate limited-   https://github.com/PhotonVision/photonvision/commits/master/ | sleeping...
-rate limited-   https://github.com/PhotonVision/photonvision/commits/master/ | sleeping...
(docs/advanced-installation/prerelease-software: line    9) broken    https://github.com/PhotonVision/photonvision/commits/master/ - 429 Client Error: Too Many Requests for url: https://github.com/PhotonVision/photonvision/commits/master/
```
2024-11-16 20:24:27 -06:00
Craig Schardt
eb395414ab Explain how to install older version of PhotonVision on Romi (#1577)
Provide instructions for installing the PhotonVision version that is
compatible with WPILibPi 2023.4.2, which is the newest version available
for the Romi.

The older text is hidden in a comment so that it can be restored when
there is a newer version of WPILibPi that is compatible with newer
versions of PhotonVision.
2024-11-16 17:16:10 -06:00
Lucien Morey
04191efc51 sphinxify java docs for python code (#1575) 2024-11-15 18:01:33 -08:00
Cameron (3539)
9bbf49bc6b [docs] Create quick-start guide (#1528)
Add a quick-start guide to help answer more questions with fewer words.
---------

Co-authored-by: Matt M <matthew.morley.ca@gmail.com>
2024-11-14 22:56:02 -05:00
Lucien Morey
dfed7e3621 Break up masssive python overload hacks (#1573)
What it says on the tin. This is all stuff from our initial effort to
port the sim things. Right now it is coupled to #1557 because this fixes
things up in that. Lets merge that one before dealing with this one
2024-11-14 14:59:55 -08:00
Cameron (3539)
4dc4ae88de Update libcamera version (#1566)
This uses the version of libcamera-gl-driver that was built using our
image. This assumes
the correct update path of the pi image version to libcamera to
photonvision.
2024-11-14 11:26:28 -05:00
Lucien Morey
c50c657193 Add Python test harness for openCVHelp class (#1557) 2024-11-14 11:10:08 -05:00
Matt
c04e13ef93 Fix roborio duplicate .so's on deploy (#1571) 2024-11-14 01:52:23 -05:00
Jade
5f3dc152c3 [photon-targeting] Remove dependency on wpilibc (#1544)
Closes https://github.com/PhotonVision/photonvision/pull/1543/files

Signed-off-by: Jade Turner <spacey-sooty@proton.me>
2024-11-14 00:25:32 -05:00
Lucien Morey
a64491a59e [photonlibpy] add mypy to ci (#1570)
Co-authored-by: James Ward <james@thedropbears.org.au>
2024-11-13 10:39:02 -05:00
Lucien Morey
a7319ce1d6 [photonlibpy] bump python dependencies for 2025 (#1567)
I tasked my team with updating our upcoming Reefscape codebase to target
2025 packages, only to realise we have created an unsolvable dependency
nightmare with these things being neglected...
2024-11-13 10:38:44 -05:00
Lucien Morey
02c94ea7ed [photonlibpy] stop getting full stack trace on test failure (#1568)
Signal/noise ratio is too low with this enabled. When dealing with #1567
I got ~46000 lines of errors going around in a circle rather than just
an import failure at the scope of each failed test
2024-11-13 10:38:17 -05:00
Gold856
c7ed37789e [photon-targeting] Fix JNI loading (#1563) 2024-11-13 10:37:51 -05:00
James Ward
744e522aea Correct yet more python type hinting (#1555) 2024-11-12 11:17:27 -05:00
140 changed files with 2172 additions and 1144 deletions

View File

@@ -39,8 +39,20 @@ jobs:
name: built-client
path: photon-client/dist/
build-examples:
name: "Build Examples"
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
include:
- os: windows-2022
architecture: x64
- os: macos-14
architecture: aarch64
- os: ubuntu-22.04
name: "Photonlib - Build Examples - ${{ matrix.os }}"
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -56,23 +68,14 @@ jobs:
- name: Install RoboRIO Toolchain
run: ./gradlew installRoboRioToolchain
# Need to publish to maven local first, so that C++ sim can pick it up
# Still haven't figured out how to make the vendordep file be copied before trying to build examples
- name: Publish photonlib to maven local
run: |
chmod +x gradlew
./gradlew publishtomavenlocal -x check
run: ./gradlew photon-targeting:publishtomavenlocal photon-lib:publishtomavenlocal -x check
- name: Build Java examples
working-directory: photonlib-java-examples
run: |
chmod +x gradlew
./gradlew copyPhotonlib -x check
./gradlew build -x check
run: ./gradlew build
- name: Build C++ examples
working-directory: photonlib-cpp-examples
run: |
chmod +x gradlew
./gradlew copyPhotonlib -x check
./gradlew build -x check
run: ./gradlew build
build-gradle:
name: "Gradle Build"
runs-on: ubuntu-22.04
@@ -92,9 +95,7 @@ jobs:
- name: Install mrcal deps
run: sudo apt-get update && sudo apt-get install -y libcholmod3 liblapack3 libsuitesparseconfig5
- name: Gradle Build
run: |
chmod +x gradlew
./gradlew photon-targeting:build photon-core:build photon-server:build -x check
run: ./gradlew photon-targeting:build photon-core:build photon-server:build -x check
- name: Gradle Tests
run: ./gradlew testHeadless -i --stacktrace
- name: Gradle Coverage
@@ -153,7 +154,6 @@ jobs:
# Generate the JSON and give it the ""standard""" name maven gives it
- run: |
chmod +x gradlew
./gradlew photon-lib:generateVendorJson
export VERSION=$(git describe --tags --match=v*)
mv photon-lib/build/generated/vendordeps/photonlib.json photon-lib/build/generated/vendordeps/photonlib-$(git describe --tags --match=v*).json
@@ -193,9 +193,7 @@ jobs:
distribution: temurin
architecture: ${{ matrix.architecture }}
- run: git fetch --tags --force
- run: |
chmod +x gradlew
./gradlew photon-targeting:build photon-lib:build -i
- run: ./gradlew photon-targeting:build photon-lib:build -i
name: Build with Gradle
- run: ./gradlew photon-lib:publish photon-targeting:publish
name: Publish
@@ -236,13 +234,9 @@ jobs:
git config --global --add safe.directory /__w/photonvision/photonvision
- name: Build PhotonLib
# We don't need to run tests, since we specify only non-native platforms
run: |
chmod +x gradlew
./gradlew photon-targeting:build photon-lib:build ${{ matrix.build-options }} -i -x test
run: ./gradlew photon-targeting:build photon-lib:build ${{ matrix.build-options }} -i -x test
- name: Publish
run: |
chmod +x gradlew
./gradlew photon-lib:publish photon-targeting:publish ${{ matrix.build-options }}
run: ./gradlew photon-lib:publish photon-targeting:publish ${{ matrix.build-options }}
env:
ARTIFACTORY_API_KEY: ${{ secrets.ARTIFACTORY_API_KEY }}
if: github.event_name == 'push' && github.repository_owner == 'photonvision'
@@ -338,13 +332,9 @@ jobs:
with:
name: built-docs
path: photon-server/src/main/resources/web/docs
- run: |
chmod +x gradlew
./gradlew photon-targeting:jar photon-server:shadowJar -PArchOverride=${{ matrix.arch-override }}
- run: ./gradlew photon-targeting:jar photon-server:shadowJar -PArchOverride=${{ matrix.arch-override }}
if: ${{ (matrix.arch-override != 'none') }}
- run: |
chmod +x gradlew
./gradlew photon-server:shadowJar
- run: ./gradlew photon-server:shadowJar
if: ${{ (matrix.arch-override == 'none') }}
- uses: actions/upload-artifact@v4
with:
@@ -585,7 +575,7 @@ jobs:
dispatch:
name: dispatch
needs: [build-photonlib-vendorjson]
needs: [build-photonlib-vendorjson, release]
runs-on: ubuntu-22.04
steps:
- uses: peter-evans/repository-dispatch@v3

View File

@@ -61,9 +61,7 @@ jobs:
with:
java-version: 17
distribution: temurin
- run: |
chmod +x gradlew
./gradlew spotlessCheck
- run: ./gradlew spotlessCheck
name: Run spotless
client-lint-format:

View File

@@ -12,6 +12,9 @@ on:
- 'docs/**'
- '.github/**'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
jobs:
build:
runs-on: ubuntu-22.04

View File

@@ -37,7 +37,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel pytest
pip install setuptools wheel pytest mypy
- name: Build wheel
working-directory: ./photon-lib/py
@@ -50,6 +50,13 @@ jobs:
pip install --no-cache-dir dist/*.whl
pytest
- 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
- name: Upload artifacts
uses: actions/upload-artifact@master

24
.gitignore vendored
View File

@@ -131,27 +131,12 @@ New client/photon-client/*
*.jfr
.DS_Store
# *.iml
photon-server/build
photon-server/photon-vision
photon-server/src/main/resources/web
photon-server/src/main/java/org/photonvision/PhotonVersion.java
photon-server/src/main/generated/native/include/org_photonvision_raspi_PicamJNI.h
*.bin
.gradle
.gradle/*
photonvision_config
build/spotlessJava
build/*
build
photon-lib/src/main/java/org/photonvision/PhotonVersion.java
photon-lib/bin/main/images/*
/photonlib-java-examples/bin/
photon-lib/src/generate/native/include/PhotonVersion.h
.gitattributes
lib/*
photon-server/lib/libapriltag.so
photon-server/bin/main/nativelibraries/apriltag/*
photon-server/src/main/resources/nativelibraries/apriltag/*
bin*/
build*/
photonlib-java-examples/*/vendordeps/*
photonlib-cpp-examples/*/vendordeps/*
@@ -161,10 +146,7 @@ photonlib-cpp-examples/*/vendordeps/*
photonlib-cpp-examples/*/networktables.json.bck
photonlib-java-examples/*/networktables.json.bck
*.sqlite
photon-server/src/main/resources/web/index.html
photon-lib/src/generate/native/cpp/PhotonVersion.cpp
photon-server/src/main/resources/web/*
venv
.venv/*
.venv

View File

@@ -36,10 +36,10 @@ ext {
openCVversion = "4.8.0-4"
joglVersion = "2.4.0"
javalinVersion = "5.6.2"
libcameraDriverVersion = "dev-v2023.1.0-14-g787ab59"
libcameraDriverVersion = "dev-v2023.1.0-15-gc8988b3"
rknnVersion = "dev-v2024.0.1-4-g0db16ac"
frcYear = "2025"
mrcalVersion = "dev-v2024.0.0-24-gc1efcf0";
mrcalVersion = "dev-v2024.0.0-27-g41d7868";
pubVersion = versionString
@@ -67,7 +67,7 @@ spotless {
java {
target fileTree('.') {
include '**/*.java'
exclude '**/build/**', '**/build-*/**', "photon-core\\src\\main\\java\\org\\photonvision\\PhotonVersion.java", "photon-lib\\src\\main\\java\\org\\photonvision\\PhotonVersion.java", "**/src/generated/**"
exclude '**/build/**', '**/build-*/**', '**/src/generated/**'
}
toggleOffOn()
googleJavaFormat()

View File

@@ -10,7 +10,8 @@
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
# import os
import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))
@@ -120,15 +121,33 @@ html_theme_options = {
"color-api-overall": "#101010",
"color-inline-code-background": "#0d0d0d",
},
"footer_icons": [
{
"name": "GitHub",
"url": "https://github.com/photonvision/photonvision",
"html": """
<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"></path>
</svg>
""",
"class": "",
},
],
}
suppress_warnings = ["epub.unknown_project_files"]
sphinx_tabs_valid_builders = ["epub", "linkcheck"]
# -- Options for linkcheck -------------------------------------------------
# Excluded links for linkcheck
# These should be periodically checked by hand to ensure that they are still functional
linkcheck_ignore = ["https://www.raspberrypi.com/software/"]
linkcheck_ignore = [R"https://www.raspberrypi.com/software/", R"http://10\..+"]
token = os.environ.get("GITHUB_TOKEN", None)
if token:
linkcheck_auth = [(R"https://github.com/.+", token)]
# MyST configuration (https://myst-parser.readthedocs.io/en/latest/configuration.html)
myst_enable_extensions = ["colon_fence"]

View File

@@ -1,6 +1,6 @@
# Filesystem Directory
PhotonVision stores and loads settings in the {code}`photonvision_config` directory, in the same folder as the PhotonVision JAR is stored. On the Pi image as well as the Gloworm, this is in the {code}`/opt/photonvision` directory. The contents of this directory can be exported as a zip archive from the settings page of the interface, under "export settings". This export will contain everything detailed below. These settings can later be uploaded using "import settings", to restore configurations from previous backups.
PhotonVision stores and loads settings in the {code}`photonvision_config` directory, in the same folder as the PhotonVision JAR is stored. On supported hardware, this is in the {code}`/opt/photonvision` directory. The contents of this directory can be exported as a zip archive from the settings page of the interface, under "export settings". This export will contain everything detailed below. These settings can later be uploaded using "import settings", to restore configurations from previous backups.
## Directory Structure
@@ -12,20 +12,20 @@ The directory structure is outlined below.
```
- calibImgs
- Images saved from the last run of the calibration routine
- Images saved from the last run of the calibration routine
- cameras
- Contains a subfolder for each camera. This folder contains the following files:
- pipelines folder, which contains a {code}`json` file for each user-created pipeline.
- config.json, which contains all camera-specific configuration. This includes FOV, pitch, current pipeline index, and calibration data
- drivermode.json, which contains settings for the driver mode pipeline
- Contains a subfolder for each camera. This folder contains the following files:
- pipelines folder, which contains a {code}`json` file for each user-created pipeline.
- config.json, which contains all camera-specific configuration. This includes FOV, pitch, current pipeline index, and calibration data
- drivermode.json, which contains settings for the driver mode pipeline
- imgSaves
- Contains images saved with the input/output save commands.
- Contains images saved with the input/output save commands.
- logs
- Contains timestamped logs in the format {code}`photonvision-YYYY-MM-D_HH-MM-SS.log`. Note that on Pi or Gloworm these timestamps will likely be significantly behind the real time.
- Contains timestamped logs in the format {code}`photonvision-YYYY-MM-D_HH-MM-SS.log`. These timestamps will likely be significantly behind the real time. Coprocessors on the robot have no way to get current time.
- hardwareSettings.json
- Contains hardware settings. Currently this includes only the LED brightness.
- Contains hardware settings. Currently this includes only the LED brightness.
- networkSettings.json
- Contains network settings, including team number (or remote network tables address), static/dynamic settings, and hostname.
- Contains network settings, including team number (or remote network tables address), static/dynamic settings, and hostname.
## Importing and Exporting Settings
@@ -41,10 +41,10 @@ The entire settings directory can be exported as a ZIP archive from the settings
A variety of files can be imported back into PhotonVision:
- ZIP Archive ({code}`.zip`)
- Useful for restoring a full configuration from a different PhotonVision instance.
- Useful for restoring a full configuration from a different PhotonVision instance.
- Single Config File
- Currently-supported Files
- {code}`hardwareConfig.json`
- {code}`hardwareSettings.json`
- {code}`networkSettings.json`
- Useful for simple hardware or network configuration tasks without overwriting all settings.
- Currently-supported Files
- {code}`hardwareConfig.json`
- {code}`hardwareSettings.json`
- {code}`networkSettings.json`
- Useful for simple hardware or network configuration tasks without overwriting all settings.

View File

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

View File

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -1,6 +1,6 @@
# Installation & Setup
# Advanced Installation
This page will help you install PhotonVision on your coprocessor, wire it, and properly setup the networking in order to start tracking targets.
This page will help you install PhotonVision on non-supported coprocessor.
## Step 1: Software Install
@@ -14,25 +14,5 @@ You only need to install PhotonVision on the coprocessor/device that is being us
:maxdepth: 3
sw_install/index
updating
```
## Step 2: Wiring
This section will walk you through how to wire your coprocessor to get power.
```{toctree}
:maxdepth: 1
wiring
```
## Step 3: Networking
This section will walk you though how to connect your coprocessor to a network. This section is very important (and easy to get wrong), so we recommend you read it thoroughly.
```{toctree}
:maxdepth: 1
networking
prerelease-software
```

View File

@@ -0,0 +1,23 @@
# Installing Pre-Release Versions
Pre-release/development version of PhotonVision can be tested by installing/downloading artifacts from Github Actions (see below), which are built automatically on commits to open pull requests and to PhotonVision's `master` branch, or by {ref}`compiling PhotonVision locally <docs/contributing/building-photon:Build Instructions>`.
:::{warning}
If testing a pre-release version of PhotonVision with a robot, PhotonLib must be updated to match the version downloaded! If not, packet schema definitions may not match and unexpected things will occur. To update PhotonLib, refer to {ref}`installing specific version of PhotonLib<docs/programming/photonlib/adding-vendordep:Install Specific Version - Java/C++>`.
:::
GitHub Actions builds pre-release version of PhotonVision automatically on PRs and on each commit merged to master. To test a particular commit to master, navigate to the [PhotonVision commit list](https://github.com/PhotonVision/photonvision/commits/master/) and click on the check mark (below). Scroll to "Build / Build fat JAR - PLATFORM", click details, and then summary. From here, JAR and image files can be downloaded to be flashed or uploaded using "Offline Update".
```{image} images/gh_actions_1.png
:alt: Github Actions Badge
```
```{image} images/gh_actions_2.png
:alt: Github Actions artifact list
```
Built JAR files (but not image files) can also be downloaded from PRs before they are merged. Navigate to the PR in GitHub, and select Checks at the top. Click on "Build" to display the same artifact list as above.
```{image} images/gh_actions_3.png
:alt: Github Actions artifacts from PR
```

View File

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,16 +1,5 @@
# Software Installation
## Supported Coprocessors
```{toctree}
:maxdepth: 1
raspberry-pi
limelight
orange-pi
snakeyes
```
## Desktop Environments
```{toctree}
@@ -29,5 +18,4 @@ mac-os
other-coprocessors
advanced-cmd
romi
gloworm
```

View File

@@ -23,13 +23,13 @@ $ sudo reboot now
Your co-processor will require an Internet connection for this process to work correctly.
:::
For installation on any other co-processors, we recommend reading the {ref}`advanced command line documentation <docs/installation/sw_install/advanced-cmd:Advanced Command Line Usage>`.
For installation on any other co-processors, we recommend reading the {ref}`advanced command line documentation <docs/advanced-installation/sw_install/advanced-cmd:Advanced Command Line Usage>`.
## Updating PhotonVision
PhotonVision can be updated by downloading the latest jar file, copying it onto the processor, and restarting the service.
For example, from another computer, run the following commands. Substitute the correct username for "\[user\]" (e.g. Raspberry Pi uses "pi", Orange Pi uses "orangepi".)
For example, from another computer, run the following commands. Substitute the correct username for "\[user\]" ( Provided images use username "pi")
```bash
$ scp [jar name].jar [user]@photonvision.local:~/

View File

@@ -0,0 +1,43 @@
# Romi Installation
The [Romi](https://docs.wpilib.org/en/latest/docs/romi-robot/index.html) is a small robot that can be controlled with the WPILib software. The main controller is a Raspberry Pi that must be imaged with [WPILibPi](https://docs.wpilib.org/en/latest/docs/romi-robot/imaging-romi.html) .
## Installation
The WPILibPi image includes FRCVision, which reserves USB cameras; to use PhotonVision, we need to edit the `/home/pi/runCamera` script to disable it. First we will need to make the file system writeable; the easiest way to do this is to go to `10.0.0.2` and choose "Writable" at the top.
SSH into the Raspberry Pi (using Windows command line, or a tool like [Putty](https://www.chiark.greenend.org.uk/~sgtatham/putty/) ) at the Romi's default address `10.0.0.2`. The default user is `pi`, and the password is `raspberry`.
:::.. The following paragraph can be restored when WPILibPi becomes compatible with the current version of PhotonVision.
:::.. Follow the process for installing PhotonVision on {ref}`"Other Debian-Based Co-Processor Installation" <docs/advanced-installation/sw_install/other-coprocessors:Other Debian-Based Co-Processor Installation>`. As it mentions, this will require an internet connection so connecting the Raspberry Pi to an internet-connected router via an Ethernet cable will be the easiest solution. The pi must remain writable while you are following these steps!
:::..Temporary instructions explaining how to install the older version of PhotonVision on a Romi. Remove when no longer needed.
:::{attention}
The version of WPILibPi for the Romi is 2023.2.1, which is not compatible with the current version of PhotonVision. **If you are using WPILibPi 2023.2.1 on your Romi, you must install PhotonVision v2023.4.2 or earlier!**
To install a compatible version of PhotonVision, enter these commands in the SSH terminal connected to the Raspberry Pi. This will download and run the install script, which will intall PhotonVision on your Raspberry Pi and configure it to run at startup.
```bash
$ wget https://git.io/JJrEP -O install.sh
$ sudo chmod +x install.sh
$ sudo ./install.sh -v 2023.4.2
```
The install script requires an internet connection, so connecting the Raspberry Pi to an internet-connected router via an Ethernet cable will be the easiest solution. The pi must remain writable while you are following these steps!
:::
:::..End of temporary instructions.
Next, from the SSH terminal, run `sudo nano /home/pi/runCamera` then arrow down to the start of the exec line and press "Enter" to add a new line. Then add `#` before the exec command to comment it out. Then, arrow up to the new line and type `sleep 10000`. Hit "Ctrl + O" and then "Enter" to save the file. Finally press "Ctrl + X" to exit nano. Now, reboot the Romi by typing `sudo reboot now`.
```{image} images/nano.png
```
After the Romi reboots, you should be able to open the PhotonVision UI at: [`http://10.0.0.2:5800/`](http://10.0.0.2:5800/). From here, you can adjust {ref}`Settings <docs/settings:Settings>` and configure {ref}`Pipelines <docs/pipelines/index:Pipelines>`.
:::{warning}
In order for settings, logs, etc. to be saved / take effect, ensure that PhotonVision is in writable mode.
:::
:::{attention}
When using an older version of PhotonVision, the user interface and features may be different than what appears in the online documentation. The [Documentation](http://10.0.0.2:5800/#/docs) link in the User Interface will open a bundled version of the documentation that matches the PhotonVision version running on your coprocessor.
:::

View File

@@ -1,6 +1,6 @@
# 2D AprilTag Tuning / Tracking
## Tracking Apriltags
## Tracking AprilTags
Before you get started tracking AprilTags, ensure that you have followed the previous sections on installation, wiring and networking. Next, open the Web UI, go to the top right card, and switch to the "AprilTag" or "Aruco" type. You should see a screen similar to the one below.

View File

@@ -1,4 +1,4 @@
# About Apriltags
# About AprilTags
```{image} images/pv-apriltag.png
:align: center

View File

@@ -18,7 +18,7 @@ You must install a set of Python dependencies in order to build the documentatio
In order to build the documentation, you can run the following command in the docs sub-folder. This will automatically build docs every time a file changes, and serves them locally at `localhost:8000` by default.
`~/photonvision/docs$ sphinx-autobuild --open-browser source/_build/html`
`~/photonvision/docs$ sphinx-autobuild --open-browser source source/_build/html`
## Opening the Documentation

View File

@@ -139,25 +139,7 @@ The `deploy` command is tested against Raspberry Pi coprocessors. Other similar
### Using PhotonLib Builds
The build process includes the following task:
```{eval-rst}
.. tab-set::
.. tab-item:: Linux
``./gradlew generateVendorJson``
.. tab-item:: macOS
``./gradlew generateVendorJson``
.. tab-item:: Windows (cmd)
``gradlew generateVendorJson``
```
This generates a vendordep JSON of your local build at `photon-lib/build/generated/vendordeps/photonlib.json`.
The build process automatically generates a vendordep JSON of your local build at `photon-lib/build/generated/vendordeps/photonlib.json`.
The photonlib source can be published to your local maven repository after building:
@@ -247,17 +229,15 @@ You can run one of the many built in examples straight from the command line, to
#### Running C++/Java
PhotonLib must first be published to your local maven repository, then the copy PhotonLib task will copy the generated vendordep json file into each example. After that, the simulateJava/simulateNative task can be used like a normal robot project. Robot simulation with attached debugger is technically possible by using simulateExternalJava and modifying the launch script it exports, though not yet supported.
PhotonLib must first be published to your local maven repository. This will also copy the generated vendordep json file into each example. After that, the simulateJava/simulateNative task can be used like a normal robot project. Robot simulation with attached debugger is technically possible by using simulateExternalJava and modifying the launch script it exports, though not yet supported.
```
~/photonvision$ ./gradlew publishToMavenLocal
~/photonvision$ cd photonlib-java-examples
~/photonvision/photonlib-java-examples$ ./gradlew copyPhotonlib
~/photonvision/photonlib-java-examples$ ./gradlew <example-name>:simulateJava
~/photonvision$ cd photonlib-cpp-examples
~/photonvision/photonlib-cpp-examples$ ./gradlew copyPhotonlib
~/photonvision/photonlib-cpp-examples$ ./gradlew <example-name>:simulateNative
```

View File

@@ -2,7 +2,7 @@
## Description
PhotonVision is a free, fast, and easy-to-use vision processing solution for the *FIRST*Robotics Competition. PhotonVision is designed to get vision working on your robot *quickly*, without the significant cost of other similar solutions.
PhotonVision is a free, fast, and easy-to-use vision processing solution for the _FIRST_ Robotics Competition. PhotonVision is designed to get vision working on your robot _quickly_, without the significant cost of other similar solutions.
Using PhotonVision, teams can go from setting up a camera and coprocessor to detecting and tracking AprilTags and other targets by simply tuning sliders. With an easy to use interface, comprehensive documentation, and a feature rich vendor dependency, no experience is necessary to use PhotonVision. No matter your resources, using PhotonVision is easy compared to its alternatives.
## Advantages

View File

@@ -7,15 +7,15 @@ The following example is from the PhotonLib example repository ([Java](https://g
- A Robot
- A camera mounted rigidly to the robot's frame, cenetered and pointed forward.
- A coprocessor running PhotonVision with an AprilTag or Aurco 2D Pipeline.
- [A printout of Apriltag 7](https://firstfrc.blob.core.windows.net/frc2024/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/frc2024/FieldAssets/Apriltag_Images_and_User_Guide.pdf), mounted on a rigid and flat surface.
## Code
Now that you have properly set up your vision system and have tuned a pipeline, you can now aim your robot at an AprilTag using the data from PhotonVision. The *yaw* of the target is the critical piece of data that will be needed first.
Now that you have properly set up your vision system and have tuned a pipeline, you can now aim your robot at an AprilTag using the data from PhotonVision. The _yaw_ of the target is the critical piece of data that will be needed first.
Yaw is reported to the roboRIO over Network Tables. PhotonLib, our vender dependency, is the easiest way to access this data. The documentation for the Network Tables API can be found {ref}`here <docs/additional-resources/nt-api:Getting Target Information>` and the documentation for PhotonLib {ref}`here <docs/programming/photonlib/adding-vendordep:What is PhotonLib?>`.
In this example, while the operator holds a button down, the robot will turn towards the AprilTag using the P term of a PID loop. To learn more about how PID loops work, how WPILib implements them, and more, visit [Advanced Controls (PID)](https://docs.wpilib.org/en/stable/docs/software/advanced-control/introduction/index.html) and [PID Control in WPILib](https://docs.wpilib.org/en/stable/docs/software/advanced-controls/controllers/pidcontroller.html#pid-control-in-wpilib).
In this example, while the operator holds a button down, the robot will turn towards the AprilTag using the P term of a PID loop. To learn more about how PID loops work, how WPILib implements them, and more, visit [Advanced Controls (PID)](https://docs.wpilib.org/en/stable/docs/software/advanced-control/introduction/index.html) and [PID Control in WPILib](https://docs.wpilib.org/en/stable/docs/software/advanced-controls/controllers/pidcontroller.html#pid-control-in-wpilib).
```{eval-rst}
.. tab-set::

View File

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -1,6 +1,6 @@
# Selecting Hardware
In order to use PhotonVision, you need a coprocessor and a camera. This page will help you select the right hardware for your team depending on your budget, needs, and experience.
In order to use PhotonVision, you need a coprocessor and a camera. Other than the recommended hardware found in the {ref}`quick start guide<docs/quick-start/common-setups:Common Hardware Setups>`, this page will help you select hardware that should work for photonvision even though it is not supported/recommended.
## Choosing a Coprocessor
@@ -11,27 +11,19 @@ In order to use PhotonVision, you need a coprocessor and a camera. This page wil
- CPU: ARM Cortex-A53 (the CPU on Raspberry Pi 3) or better
- At least 8GB of storage
- 2GB of RAM
- PhotonVision isn't very RAM intensive, but you'll need at least 2GB to run the OS and PhotonVision.
- PhotonVision isn't very RAM intensive, but you'll need at least 2GB to run the OS and PhotonVision.
- The following IO:
- At least 1 USB or MIPI-CSI port for the camera
- Note that we only support using the Raspberry Pi's MIPI-CSI port, other MIPI-CSI ports from other coprocessors may not work.
- Ethernet port for networking
- At least 1 USB or MIPI-CSI port for the camera
- Note that we only support using the Raspberry Pi's MIPI-CSI port, other MIPI-CSI ports from other coprocessors will probably not work.
- Ethernet port for networking
### Coprocessor Recommendations
When selecting a coprocessor, it is important to consider various factors, particularly when it comes to AprilTag detection. Opting for a coprocessor with a more powerful CPU can generally result in higher FPS AprilTag detection, leading to more accurate pose estimation. However, it is important to note that there is a point of diminishing returns, where the benefits of a more powerful CPU may not outweigh the additional cost. Below is a list of supported hardware, along with some notes on each.
- Orange Pi 5 (\$99)
- This is the recommended coprocessor for most teams. It has a powerful CPU that can handle AprilTag detection at high FPS, and is relatively cheap compared to processors of a similar power.
- Raspberry Pi 4/5 (\$55-\$80)
- This is the recommended coprocessor for teams on a budget. It has a less powerful CPU than the Orange Pi 5, but is still capable of running PhotonVision at a reasonable FPS.
- Mini PCs (such as Beelink N5095)
- This coprocessor will likely have similar performance to the Orange Pi 5 but has a higher performance ceiling (when using more powerful CPUs). Do note that this would require extra effort to wire to the robot / get set up. More information can be found in the set up guide [here.](https://docs.google.com/document/d/1lOSzG8iNE43cK-PgJDDzbwtf6ASyf4vbW8lQuFswxzw/edit?usp=drivesdk)
- Other coprocessors can be used but may require some extra work / command line usage in order to get it working properly.
When selecting a coprocessor, it is important to consider various factors, particularly when it comes to AprilTag detection. Opting for a coprocessor with a more powerful CPU can generally result in higher FPS AprilTag detection, leading to more accurate pose estimation. However, it is important to note that there is a point of diminishing returns, where the benefits of a more powerful CPU may not outweigh the additional cost. Other coprocessors can be used but may require some extra work / command line usage in order to get it working properly.
## Choosing a Camera
PhotonVision works with Pi Cameras and most USB Cameras, the recommendations below are known to be working and have been tested. Other cameras such as webcams, virtual cameras, etc. are not officially supported and may not work. It is important to note that fisheye cameras should only be used as a driver camera and not for detecting targets.
PhotonVision works with Pi Cameras and most USB Cameras. Other cameras such as webcams, virtual cameras, etc. are not officially supported and may not work. It is important to note that fisheye cameras should only be used as a driver camera / gamepeice detection and not for detecting targets / AprilTags.
PhotonVision relies on [CSCore](https://github.com/wpilibsuite/allwpilib/tree/main/cscore) to detect and process cameras, so camera support is determined based off compatibility with CScore along with native support for the camera within your OS (ex. [V4L compatibility](https://en.wikipedia.org/wiki/Video4Linux) if using a Linux machine like a Raspberry Pi).
@@ -43,31 +35,17 @@ Logitech Cameras and integrated laptop cameras will not work with PhotonVision d
We do not currently support the usage of two of the same camera on the same coprocessor. You can only use two or more cameras if they are of different models or they are from Arducam, which has a [tool that allows for cameras to be renamed](https://docs.arducam.com/UVC-Camera/Serial-Number-Tool-Guide/).
:::
### Recommended Cameras
### Cameras Attributes
For colored shape detection, any non-fisheye camera supported by PhotonVision will work. We recommend the Pi Camera V1 or a high fps USB camera.
For colored shape detection, any non-fisheye camera supported by PhotonVision will work. We recommend a high fps USB camera.
For driver camera, we recommend a USB camera with a fisheye lens, so your driver can see more of the field.
For AprilTag detection, we recommend you use a global shutter camera that has ~100 degree diagonal FOV. This will allow you to see more AprilTags in frame, and will allow for more accurate pose estimation. You also want a camera that supports high FPS, as this will allow you to update your pose estimator at a higher frequency.
- Recommendations For AprilTag Detection
- Arducam USB OV9281
- This is the recommended camera for AprilTag detection as it is a high FPS, global shutter camera USB camera that has a ~70 degree FOV.
- Innomaker OV9281
- Spinel AR0144
- Pi Camera Module V1
- The V1 is strongly preferred over the V2 due to the V2 having undesirable FOV choices
Another cause of image distortion is 'rolling shutter.' This occurs when the camera captures pixels sequentially from top to bottom, which can also lead to distortion if the camera or object is moving.
### AprilTags and Motion Blur
When detecting AprilTags, you want to reduce the "motion blur" as much as possible. Motion blur is the visual streaking/smearing on the camera stream as a result of movement of the camera or object of focus. You want to mitigate this as much as possible because your robot is constantly moving and you want to be able to read as many tags as you possibly can. The possible solutions to this include:
1. Cranking your exposure as low as it goes and increasing your gain/brightness. This will decrease the effects of motion blur and increase FPS.
2. Using a global shutter (as opposed to rolling shutter) camera. This should eliminate most, if not all motion blur.
3. Only rely on tags when not moving.
```{image} images/motionblur.gif
```{image} images/rollingshutter.gif
:align: center
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

View File

@@ -1,60 +0,0 @@
# Gloworm Installation
While not currently in production, PhotonVision still supports Gloworm vision processing cameras.
## Downloading the Gloworm Image
Download the latest [Gloworm/Limelight release of PhotonVision](https://github.com/photonvision/photonvision/releases); the image will be suffixed with "image_limelight2.xz". You do not need to extract the downloaded archive.
## Flashing the Gloworm Image
Plug a USB C cable from your computer into the USB C port on Gloworm labeled with a download icon.
Use the 1.18.11 version of [Balena Etcher](https://github.com/balena-io/etcher/releases/tag/v1.18.11) to flash an image onto the coprocessor.
Run BalenaEtcher as an administrator. Select the downloaded `.zip` file.
Select the compute module. If it doesn't show up after 30s try using another USB port, initialization may take a while. If prompted, install the recommended missing drivers.
Hit flash. Wait for flashing to complete, then disconnect your USB C cable.
:::{warning}
Using a version of Balena Etcher older than 1.18.11 may cause bootlooping (the system will repeatedly boot and restart) when imaging your Gloworm. Updating to 1.18.11 will fix this issue.
:::
## Final Steps
Power your device per its documentation and connect it to a robot network.
You should be able to locate the camera at `http://photonvision.local:5800/` in your browser on your computer when connected to the robot.
## Troubleshooting/Setting a Static IP
A static IP address may be used as an alternative to the mDNS `photonvision.local` address.
Download and run [Angry IP Scanner](https://angryip.org/download/#windows) to find PhotonVision/your coprocessor on your network.
```{image} images/angryIP.png
```
Once you find it, set the IP to a desired {ref}`static IP in PhotonVision. <docs/settings:Networking>`
## Updating PhotonVision
Download the latest stable .jar from [the releases page](https://github.com/PhotonVision/photonvision/releases), go to the settings tab, and upload the .jar using the Offline Update button.
:::{note}
If you are updating PhotonVision on a Gloworm/Limelight, download the LinuxArm64 .jar file.
:::
As an alternative option - Export your settings, reimage your coprocessor using the instructions above, and import your settings back in.
## Hardware Troubleshooting
To turn the LED lights off or on you need to modify the `ledMode` network tables entry or the `camera.setLED` of PhotonLib.
## Support Links
- [Website/Documentation](https://photonvision.github.io/gloworm-docs/docs/quickstart/#finding-gloworm) (Note: Gloworm is no longer in production)
- [Image](https://github.com/gloworm-vision/pi-img-updator/releases)
- [Discord](https://discord.com/invite/DncQRky)

View File

@@ -1,24 +0,0 @@
# Limelight Installation
## Imaging
Limelight imaging is a very similar process to Gloworm, but with extra steps.
### Base Install Steps
Due to the similarities in hardware, follow the {ref}`Gloworm install instructions <docs/installation/sw_install/gloworm:Gloworm Installation>`.
## Hardware-Specific Steps
Download the hardwareConfig.json file for the version of your Limelight:
- {download}`Limelight Version 2 <files/Limelight2/hardwareConfig.json>`.
- {download}`Limelight Version 2+ <files/Limelight2+/hardwareConfig.json>`.
:::{note}
No hardware config is provided for the Limelight 3 as AprilTags do not require the LEDs (meaning nobody has reverse-engineered what I/O pins drive the LEDs) and the camera FOV is determined as part of calibration.
:::
{ref}`Import the hardwareConfig.json file <docs/additional-resources/config:Importing and Exporting Settings>`. Again, this is **REQUIRED** or target measurements will be incorrect, and LEDs will not work.
After installation you should be able to [locate the camera](https://photonvision.github.io/gloworm-docs/docs/quickstart/#finding-gloworm) at: `http://photonvision.local:5800/` (not `gloworm.local`, as previously)

View File

@@ -1,39 +0,0 @@
# Orange Pi Installation
## Downloading Linux Image
Starting in 2024, PhotonVision provides pre-configured system images for Orange Pi 5 devices. Download the latest release of the PhotonVision Orange Pi 5 image (.xz file suffixed with `orangepi5.xz`) from the [releases page](https://github.com/PhotonVision/photonvision/releases). You do not need to extract the downloaded archive file. This image is configured with a `pi` user with password `raspberry`.
For an Orange Pi 4, download the latest release of the Armbian Bullseye CLI image from [here](https://armbian.tnahosting.net/archive/orangepi4/archive/Armbian_23.02.2_Orangepi4_bullseye_current_5.15.93.img.xz).
## Flashing the Pi Image
An 8GB or larger SD card is recommended.
Use the 1.18.11 version of [Balena Etcher](https://github.com/balena-io/etcher/releases/tag/v1.18.11) to flash an image onto a Orange Pi. Select the downloaded image file, select your microSD card, and flash.
For more detailed instructions on using Etcher, please see the [Etcher website](https://www.balena.io/etcher/).
:::{warning}
Using a version of Balena Etcher older than 1.18.11 may cause bootlooping (the system will repeatedly boot and restart) when imaging your Orange Pi. Updating to 1.18.11 will fix this issue.
:::
Alternatively, you can use the [Raspberry Pi Imager](https://www.raspberrypi.com/software/) to flash the image.
Select "Choose OS" and then "Use custom" to select the downloaded image file. Select your microSD card and flash.
:::{note}
If you are working on Linux, "dd" can be used in the command line to flash an image.
:::
If you're using an Orange Pi 5, that's it! Orange Pi 4 users will need to install PhotonVision (see below).
### Initial User Setup (Orange Pi 4 Only)
Insert the flashed microSD card into your Orange Pi and boot it up. The first boot may take a few minutes as the Pi expands the filesystem. Be sure not to unplug during this process.
Plug your Orange Pi into a display via HDMI and plug in a keyboard via USB once its powered up. For an Orange Pi 4, complete the initial set up which involves creating a root password and adding a user, as well as setting localization language. Additionally, choose “bash” when prompted.
## Installing PhotonVision (Orange Pi 4 Only)
From here, you can follow {ref}`this guide <docs/installation/sw_install/other-coprocessors:Installing Photonvision>`.

View File

@@ -1,50 +0,0 @@
# Raspberry Pi Installation
A Pre-Built Raspberry Pi image is available for ease of installation.
## Downloading the Pi Image
Download the latest release of the PhotonVision Raspberry image (.xz file) from the [releases page](https://github.com/PhotonVision/photonvision/releases). You do not need to extract the downloaded ZIP file.
:::{note}
Make sure you download the image that ends in '-RaspberryPi.xz'.
:::
## Flashing the Pi Image
An 8GB or larger card is recommended.
Use the 1.18.11 version of [Balena Etcher](https://github.com/balena-io/etcher/releases/tag/v1.18.11) to flash an image onto a Raspberry Pi. Select the downloaded `.tar.xz` file, select your microSD card, and flash.
For more detailed instructions on using Etcher, please see the [Etcher website](https://www.balena.io/etcher/).
:::{warning}
Using a version of Balena Etcher older than 1.18.11 may cause bootlooping (the system will repeatedly boot and restart) when imaging your Raspberry Pi. Updating to 1.18.11 will fix this issue.
:::
Alternatively, you can use the [Raspberry Pi Imager](https://www.raspberrypi.com/software/) to flash the image.
Select "Choose OS" and then "Use custom" to select the downloaded image file. Select your microSD card and flash.
If you are using a non-standard Pi Camera connected to the CSI port, {ref}`additional configuration may be required. <docs/hardware/picamconfig:Pi Camera Configuration>`
## Final Steps
Insert the flashed microSD card into your Raspberry Pi and boot it up. The first boot may take a few minutes as the Pi expands the filesystem. Be sure not to unplug during this process.
After the initial setup process, your Raspberry Pi should be configured for PhotonVision. You can verify this by making sure your Raspberry Pi and computer are connected to the same network and navigating to `http://photonvision.local:5800` in your browser on your computer.
## Troubleshooting/Setting a Static IP
A static IP address may be used as an alternative to the mDNS `photonvision.local` address.
Download and run [Angry IP Scanner](https://angryip.org/download/#windows) to find PhotonVision/your coprocessor on your network.
```{image} images/angryIP.png
```
Once you find it, set the IP to a desired {ref}`static IP in PhotonVision. <docs/settings:Networking>`
## Updating PhotonVision
To upgrade a Raspberry Pi device with PhotonVision already installed, follow the {ref}`Raspberry Pi update instructions<docs/installation/updating:offline update>`.

View File

@@ -1,22 +0,0 @@
# Romi Installation
The [Romi](https://docs.wpilib.org/en/latest/docs/romi-robot/index.html) is a small robot that can be controlled with the WPILib software. The main controller is a Raspberry Pi that must be imaged with [WPILibPi](https://docs.wpilib.org/en/latest/docs/romi-robot/imaging-romi.html) .
## Installation
The WPILibPi image includes FRCVision, which reserves USB cameras; to use PhotonVision, we need to edit the `/home/pi/runCamera` script to disable it. First we will need to make the file system writeable; the easiest way to do this is to go to `10.0.0.2` and choose "Writable" at the top.
SSH into the Raspberry Pi (using Windows command line, or a tool like [Putty](https://www.chiark.greenend.org.uk/~sgtatham/putty/) ) at the Romi's default address `10.0.0.2`. The default user is `pi`, and the password is `raspberry`.
Follow the process for installing PhotonVision on {ref}`"Other Debian-Based Co-Processor Installation" <docs/installation/sw_install/other-coprocessors:Other Debian-Based Co-Processor Installation>`. As it mentions this will require an internet connection so plugging into the ethernet jack on the Raspberry Pi will be the easiest solution. The pi must remain writable!
Next, from the SSH terminal, run `sudo nano /home/pi/runCamera` then arrow down to the start of the exec line and press "Enter" to add a new line. Then add `#` before the exec command to comment it out. Then, arrow up to the new line and type `sleep 10000`. Hit "Ctrl + O" and then "Enter" to save the file. Finally press "Ctrl + X" to exit nano. Now, reboot the Romi by typing `sudo reboot`.
```{image} images/nano.png
```
After it reboots, you should be able to [locate the PhotonVision UI](https://photonvision.github.io/gloworm-docs/docs/quickstart/#finding-gloworm) at: `http://10.0.0.2:5800/`.
:::{warning}
In order for settings, logs, etc. to be saved / take effect, ensure that PhotonVision is in writable mode.
:::

View File

@@ -1,56 +0,0 @@
# SnakeEyes Installation
A Pre-Built Raspberry Pi image with configuration for [the SnakeEyes Raspberry Pi Hat](https://www.playingwithfusion.com/productview.php?pdid=133&catid=1014) is available for ease of setup.
## Downloading the SnakeEyes Image
Download the latest release of the SnakeEyes-specific PhotonVision Pi image from the [releases page](https://github.com/PlayingWithFusion/SnakeEyesDocs/releases). You do not need to extract the downloaded ZIP file.
## Flashing the SnakeEyes Image
An 8GB or larger card is recommended.
Use the 1.18.11 version of [Balena Etcher](https://github.com/balena-io/etcher/releases/tag/v1.18.11) to flash an image onto a Raspberry Pi. Select the downloaded `.zip` file, select your microSD card, and flash.
For more detailed instructions on using Etcher, please see the [Etcher website](https://www.balena.io/etcher/).
:::{warning}
Using a version of Balena Etcher older than 1.18.11 may cause bootlooping (the system will repeatedly boot and restart) when imaging your Raspberry Pi. Updating to 1.18.11 will fix this issue.
:::
Alternatively, you can use the [Raspberry Pi Imager](https://www.raspberrypi.com/software/) to flash the image.
Select "Choose OS" and then "Use custom" to select the downloaded image file. Select your microSD card and flash.
## Final Steps
Insert the flashed microSD card into your Raspberry Pi and boot it up. The first boot may take a few minutes as the Pi expands the filesystem. Be sure not to unplug during this process.
After the initial setup process, your Raspberry Pi should be configured for PhotonVision. You can verify this by making sure your Raspberry Pi and computer are connected to the same network and navigating to `http://photonvision.local:5800` in your browser on your computer.
## Troubleshooting/Setting a Static IP
A static IP address may be used as an alternative to the mDNS `photonvision.local` address.
Download and run [Angry IP Scanner](https://angryip.org/download/#windows) to find PhotonVision/your coprocessor on your network.
```{image} images/angryIP.png
```
Once you find it, set the IP to a desired {ref}`static IP in PhotonVision. <docs/settings:Networking>`
## Updating PhotonVision
Download the latest xxxxx-LinuxArm64.jar from [our releases page](https://github.com/PhotonVision/photonvision/releases), go to the settings tab, and upload the .jar using the Offline Update button.
As an alternative option - Export your settings, reimage your coprocessor using the instructions above, and import your settings back in.
## Hardware Troubleshooting
To turn the LED lights off or on you need to modify the `ledMode` network tables entry or the `camera.setLED` of PhotonLib.
## Support Links
- [Website](https://www.playingwithfusion.com/productview.php?pdid=133)
- [Image](https://github.com/PlayingWithFusion/SnakeEyesDocs/releases/latest)
- [Documentation](https://github.com/PlayingWithFusion/SnakeEyesDocs/blob/master/PhotonVision/readme.md)

View File

@@ -1,54 +0,0 @@
# Updating PhotonVision
PhotonVision provides many different files on a single release page. Each release contains JAR files for performing "offline updates" of a device with PhotonVision already installed, as well as full image files to "flash" to supported coprocessors.
```{image} images/release-page.png
:alt: Example GitHub release page
```
In the example release above, we see:
- Image files for flashing directly to supported coprocessors.
- Raspberry Pi 3/4/5/CM4: follow our {ref}`Raspberry Pi flashing instructions<docs/installation/sw_install/raspberry-pi:raspberry pi installation>`.
- For LimeLight devices: follow our {ref}`LimeLight flashing instructions<docs/installation/sw_install/limelight:imaging>`.
- For Orange Pi 5 devices: follow our {ref}`Orange Pi flashing instructions<docs/installation/sw_install/orange-pi:orange pi installation>`.
- JAR files for the suite of supported operating systems for use with Offline Update. In general:
- Raspberry Pi, Limelight, and Orange Pi: use images suffixed with -linuxarm64.jar. For example: {code}`photonvision-v2024.1.1-linuxarm64.jar`
- Beelink and other Intel/AMD-based Mini-PCs: use images suffixed with -linuxx64.jar. For example: {code}`photonvision-v2024.1.1-linuxx64.jar`
## Offline Update
Unless noted in the release page, an offline update allows you to quickly upgrade the version of PhotonVision running on a coprocessor with PhotonVision already installed on it.
Unless otherwise noted on the release page, config files should be backward compatible with previous version of PhotonVision, and this offline update process should preserve any pipelines and calibrations previously performed. For paranoia, we suggest exporting settings from the Settings tab prior to performing an offline update.
:::{note}
Carefully review release notes to ensure that reflashing the device (for supported devices) or other installation steps are not required, as dependencies needed for PhotonVision may change between releases
:::
## Installing Pre-Release Versions
Pre-release/development version of PhotonVision can be tested by installing/downloading artifacts from Github Actions (see below), which are built automatically on commits to open pull requests and to PhotonVision's `master` branch, or by {ref}`compiling PhotonVision locally <docs/contributing/building-photon:Build Instructions>`.
:::{warning}
If testing a pre-release version of PhotonVision with a robot, PhotonLib must be updated to match the version downloaded! If not, packet schema definitions may not match and unexpected things will occur. To update PhotonLib, refer to {ref}`installing specific version of PhotonLib<docs/programming/photonlib/adding-vendordep:Install Specific Version - Java/C++>`.
:::
GitHub Actions builds pre-release version of PhotonVision automatically on PRs and on each commit merged to master. To test a particular commit to master, navigate to the [PhotonVision commit list](https://github.com/PhotonVision/photonvision/commits/master/) and click on the check mark (below). Scroll to "Build / Build fat JAR - PLATFORM", click details, and then summary. From here, JAR and image files can be downloaded to be flashed or uploaded using "Offline Update".
```{image} images/gh_actions_1.png
:alt: Github Actions Badge
```
```{image} images/gh_actions_2.png
:alt: Github Actions artifact list
```
Built JAR files (but not image files) can also be downloaded from PRs before they are merged. Navigate to the PR in GitHub, and select Checks at the top. Click on "Build" to display the same artifact list as above.
```{image} images/gh_actions_3.png
:alt: Github Actions artifacts from PR
```

View File

@@ -1,42 +0,0 @@
# Wiring
## Off-Robot Wiring
Plugging your coprocessor into the wall via a power brick will suffice for off robot wiring.
:::{note}
Please make sure your chosen power supply can provide enough power for your coprocessor. Undervolting (where enough power isn't being supplied) can cause many issues.
:::
## On-Robot Wiring
:::{note}
We recommend users use the [SnakeEyes Pi Hat](https://www.playingwithfusion.com/productview.php?pdid=133) as it provides passive power over ethernet (POE) and other useful features to simplify wiring and make your life easier.
:::
### Recommended: Coprocessor with Passive POE (Gloworm, Pi with SnakeEyes, Limelight)
1. Plug the [passive POE injector](https://www.revrobotics.com/rev-11-1210/) into the coprocessor and wire it to PDP/PDH (NOT the VRM).
2. Add a breaker to relevant slot in your PDP/PDH
3. Run an ethernet cable from the passive POE injector to your network switch / radio (we *STRONGLY* recommend the usage of a network switch, see the [networking](networking.md) section for more info.)
### Coprocessor without Passive POE
1a. Option 1: Get a micro USB (may be USB-C if using a newer Pi) pigtail cable and connect the wire ends to a regulator like [this](https://www.pololu.com/product/4082). Then, wire the regulator into your PDP/PDH and the Micro USB / USB C into your coprocessor.
1b. Option 2: Use a USB power bank to power your coprocessor. Refer to this year's robot rulebook on legal implementations of this.
2. Run an ethernet cable from your Pi to your network switch / radio (we *STRONGLY* recommend the usage of a network switch, see the [networking](networking.md) section for more info.)
This diagram shows how to use the recommended regulator to power a coprocessor.
```{image} images/pololu-diagram.png
:alt: A flowchart-type diagram showing how to connect wires from the PDP or PDH to
: the recommended voltage regulator and then a Coprocessor.
```
:::{note}
The regulator comes with optional screw terminals that may be used to connect the PDP/PDH and Coprocessor power wires if you do not wish to solder them.
:::
Once you have wired your coprocessor, you are now ready to install PhotonVision.

View File

@@ -7,6 +7,7 @@ PhotonVision supports object detection using neural network accelerator hardware
For the 2024 season, PhotonVision ships with a **pre-trained NOTE detector** (shown above), as well as a mechanism for swapping in custom models. Future development will focus on enabling lower friction management of multiple custom models.
```{image} images/notes-ui.png
```
## Tracking Objects
@@ -32,6 +33,10 @@ Compared to other pipelines, object detection exposes very few tuning handles. T
The same area, aspect ratio, and target orientation/sort parameters from {ref}`reflective pipelines <docs/reflectiveAndShape/contour-filtering:Reflective>` are also exposed in the object detection card.
## Letterboxing
Photonvision will letterbox your camera frame to 640x640. This means that if you select a resolution that is larger than 640 it will be scaled down to fit inside a 640x640 frame with black bars if needed. Smaller frames will be scaled up with black bars if needed.
## Training Custom Models
Coming soon!

View File

@@ -0,0 +1,22 @@
# Arducam Cameras
Arducam cameras are supported for setups with multiple devices. This is possible because Arducam provides software that allows you to assign truly different device names to each camera. This feature is particularly useful in complex setups where multiple cameras are used simultaneously.
## Setting Up Arducam Cameras
1. **Download Arducam Software**: [Download and install the Arducam software from their official website.](https://docs.arducam.com/UVC-Camera/Serial-Number-Tool-Guide/)
2. **Assign Device Names**: Use the Arducam software and Arducam [documentation](https://docs.arducam.com/UVC-Camera/Serial-Number-Tool-Guide/) to give each camera a unique device name. This will help in distinguishing between multiple cameras in your setup.
## Steps to Configure in PhotonVision
1. **Open PhotonVision Settings**: Navigate to the cameras page in PhotonVision.
2. **Select Camera Model**: Select the proper camera. Use the Arducam model selector to specify the model of each Arducam camera connected to your system.
3. **Save Settings**: Ensure that you save the settings after selecting the appropriate camera model for each device.
```{image} images/setArducamModel.png
:alt: The camera model can be selected from the Arudcam model selector in the cameras tab
:align: center
```

View File

@@ -0,0 +1,33 @@
# Camera Calibration
:::{important}
In order to detect AprilTags and use 3D mode, your camera must be calibrated at the desired resolution! Inaccurate calibration will lead to poor performance.
:::
If youre not using cameras in 3D mode, calibration is optional, but it can still offer benefits. Calibrating cameras helps refine the pitch and yaw values, leading to more accurate positional data in every mode. {ref}`For a more in-depth view<docs/calibration/calibration:Calibrating Your Camera>`.
## Print the Calibration Target
- 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
- Pattern Spacing: 1.00in
- Marker Size: 0.75in
- Board Height : 8
- Board Width : 8
## Prepare the Calibration Target
- Measure Accurately: Use calipers to measure the actual size of the squares and markers. Accurate measurements are crucial for effective calibration.
- Ensure Flatness: The calibration board must be perfectly flat, without any wrinkles or bends, to avoid introducing errors into the calibration process.
## Calibrate your Camera
- Take lots of photos: It's recommended to capture more than 50 images to properly calibrate your camera for accuracy. 12 is the bare minimum and may not provide good results.
- Other Tips
- Move the board not the camera.
- Take photos of lots of angles: The more angles the more better (up to 45 deg).
- A couple of up close images is good.
- Cover the entire cameras fov.
- Avoid images with the board facing straight towards the camera.

View File

@@ -0,0 +1,44 @@
# Common Hardware Setups
## Coprocessors
:::{note}
The Orange Pi 5 is the only currently supported device for object detection.
:::
- Orange Pi 5 4GB
- Able to process two object detection streams at once while also processing 1 to 2 AprilTag streams at 1280x800 (30fps).
- Raspberry Pi 5 2GB
- A good cheaper option. Doesn't support object detection. Able to process 2 AprilTag streams at 1280x800 (30fps).
## SD Cards
- 8GB or larger micro SD card
- Many teams have found that an industrial micro sd card are much more stable in competition. One example is the SanDisk industrial 16GB micro SD card.
## Cameras
- AprilTag
- Innomaker or Arducam OV9281 UVC USB cameras.
- Object Detection
- Arducam OV9782 works well with its global shutter.
- Most other fixed-focus color UVC USB webcams.
- Driver Camera
- OV9281
- OV9782
- Pi Camera Module V1 {ref}`(More setup info)<docs/hardware/picamconfig:Pi Camera Configuration>`
- Most other fixed-focus UVC USB webcams
## Power
- Pololu S13V30F5 Regulator
- Wide power range input. Recommended by many teams.
- Redux Robotics Zinc-V Regulator
- Recently released for the 2025 season, offering reliable and easy integration.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 826 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -0,0 +1,13 @@
# Quick Start
```{toctree}
:maxdepth: 2
common-setups
quick-install
wiring
networking
arducam-cameras
camera-calibration
quick-configure
```

View File

@@ -2,28 +2,53 @@
## Physical Networking
:::{note}
When using PhotonVision off robot, you *MUST* plug the coprocessor into a physical router/radio. You can then connect your laptop/device used to view the webdashboard to the same network. Any other networking setup will not work and will not be supported in any capacity.
:::{warning}
When using PhotonVision off robot, you _MUST_ plug the coprocessor into a physical router/radio. You can then connect your laptop/device used to view the webdashboard to the same network. Any other networking setup will not work and will not be supported in any capacity.
:::
After imaging your coprocessor, run an ethernet cable from your coprocessor to a router/radio and power on your coprocessor by plugging it into the wall. Then connect whatever device you're using to view the webdashboard to the same network and navigate to photonvision.local:5800.
::::{tab-set}
PhotonVision *STRONGLY* recommends the usage of a network switch on your robot. This is because the second radio port on the current FRC radios is known to be buggy and cause frequent connection issues that are detrimental during competition. An in-depth guide on how to install a network switch can be found [on FRC 900's website](https://team900.org/blog/ZebraSwitch/).
:::{tab-item} New Radio (2025 - present)
```{danger}
Ensure that DIP switches 1 and 2 are turned off; otherwise, the radio PoE feature will fry your coprocessor. [More info.](https://frc-radio.vivid-hosting.net/getting-started/passive-power-over-ethernet-poe-for-downstream-devices)
```
```{image} images/networking-diagram-vividhosting.png
:alt: Wiring using a network switch and the new vivid hosting radio
```
:::
:::{tab-item} Old Radio (pre 2025)
PhotonVision _STRONGLY_ recommends the usage of a network switch on your robot. This is because the second radio port on the old FRC radios is known to be buggy and cause frequent connection issues that are detrimental during competition. An in-depth guide on how to install a network switch can be found [on FRC 900's website](https://zebracorns.org/blog/ZebraSwitch/).
```{image} images/networking-diagram.png
:alt: Correctly set static IP
:alt: Wiring using a network switch and the old open mesh radio
```
:::
::::
## Network Hostname
Rename each device from the default "Photonvision" to a unique hostname (e.g., "Photon-OrangePi-Left" or "Photon-RPi5-Back"). This helps differentiate multiple coprocessors on your network, making it easier to manage them. Navigate to the settings page and scroll down to the network section. You will find the hostname is set to "photonvision" by default, this can only contain letters (A-Z), numeric characters (0-9), and the minus sign (-).
```{image} images/editHostname.png
:alt: The hostname can be edited in the settings page under the network section.
```
## Digital Networking
PhotonVision *STRONGLY* recommends the usage of Static IPs as it increases reliability on the field and when using PhotonVision in general. To properly set up your static IP, follow the steps below:
PhotonVision _STRONGLY_ recommends the usage of Static IPs as it increases reliability on the field and when using PhotonVision in general. To properly set up your static IP, follow the steps below:
:::{warning}
Only use a static IP when connected to the **robot radio**, and never when testing at home, unless you are well versed in networking or have the relevant "know how".
:::
1. Ensure your robot is on and you are connected to the robot network.
2. Navigate to `photonvision.local:5800` (this may be different if you are using a Gloworm / Limelight) in your browser.
2. Navigate to `photonvision.local:5800`in your browser.
3. Open the settings tab on the left pane.
4. Under the Networking section, set your team number.
5. Change your IP to Static.

View File

@@ -0,0 +1,57 @@
# Quick Configure
## Settings to configure
### Team number
In order for photonvision to connect to the roborio it needs to know your team number.
### Camera Nickname
You **must** nickname your cameras in photonvision to ensure that every camera has a unique name. This is how we will identify cameras in robot code. The camera can be nickname using the edit button next to the camera name in the upper right of the Dashboard tab.
```{image} images/editCameraName.png
:align: center
```
## Pipeline Settings
### AprilTag
When using an Orange Pi 5 with an Arducam OV9281 teams will usually change the following settings. For more info on AprilTag settings please review {ref}`this<docs/apriltag-pipelines/2D-tracking-tuning:2D AprilTag Tuning / Tracking>`.
- Resolution:
- 1280x800
- Decimate:
- 2
- Mode:
- 3D
- Exposure and Gain:
- Adjust these to achieve good brightness without flicker and low motion blur. This may vary based on lighting conditions in your competition environment.
- Enable MultiTag
- Set arducam specific camera type selector to OV9281
#### AprilTags and Motion Blur and Rolling Shutter
When detecting AprilTags, it's important to minimize 'motion blur' as much as possible. Motion blur appears as visual streaking or smearing in the camera feed, resulting from the movement of either the camera or the object in focus. Reducing this effect is essential, as the robot is often in motion, and a clearer image allows for detecting as many tags as possible. This is not to be confused with {ref}`rolling shutter<docs/hardware/selecting-hardware:Cameras Attributes>`.
- Fixes
- Lower your exposure as low as possible. Using gain and brightness to account for lack of brightness.
- Other Options:
- Don't use/rely vision measurements while moving.
```{image} images/motionblur.png
:align: center
```
### Object Detection
When using an Orange Pi 5 with an OV9782 teams will usually change the following settings. For more info on object detection settings please review {ref}`this<docs/objectDetection/about-object-detection:About Object Detection>`.
- Resolution:
- Resolutions higher than 640x640 may not result in any higher detection accuracy and may lower {ref}`performance<docs/objectDetection/about-object-detection:Letterboxing>`.
- Confidence:
- 0.75 - 0.95 Lower values are fpr detecting warn game pieces or less ideal game pieces. Higher for less warn, more ideal game pieces.
- White Balance Temperature:
- Adjust this to achieve better color accuracy. This may be needed to increase confidence.
- Set arducam specific camera type selector to OV9782

View File

@@ -0,0 +1,38 @@
# Quick Install
## Install the latest image of photonvision for your coprocessor
- For the supported coprocessors
- RPI 3,4,5
- Orange Pi 5
- Limelight
For installing on non-supported devices {ref}`see. <docs/advanced-installation/sw_install/index:Software Installation>`
[Download the latest preconfigured image of photonvision for your coprocessor](https://github.com/PhotonVision/photonvision/releases/latest)
| Coprocessor | Image filename | Jar |
| -------------------- | ---------------------------------------------------- | ------------------------------------- |
| OrangePi 5 | photonvision-{version}-linuxarm64_orangepi5.img.xz | photonvision-{version}-linuxarm64.jar |
| Raspberry Pi 3, 4, 5 | photonvision-{version}-linuxarm64_RaspberryPi.img.xz | photonvision-{version}-linuxarm64.jar |
| Limelight 2 | photonvision-{version}-linuxarm64_limelight2.img.xz | photonvision-{version}-linuxarm64.jar |
| Limelight 3 | photonvision-{version}-linuxarm64_limelight3.img.xz | photonvision-{version}-linuxarm64.jar |
:::{warning}
Balena Etcher 1.18.11 is a known working version. Other versions may cause issues such as bootlooping (the system will repeatedly boot and restart) when imaging your device.
:::
Use the 1.18.11 version of [Balena Etcher](https://github.com/balena-io/etcher/releases/tag/v1.18.11) to flash the image onto the coprocessors micro sd card. Select the downloaded `.img.xz` file, select your microSD card, and flash.
Limelights have a different installation processes. Simply connect the limelight to your computer using the proper usb cable. Select the compute module. If it doesnt show up after 30s try using another USB port, initialization may take a while. If prompted, install the recommended missing drivers. Select the image, and flash.
Unless otherwise noted in release notes or if updating from the prior years version, to update PhotonVision after the initial installation, use the offline update option in the settings page with the downloaded jar file from the latest release.
:::{note}
Limelight 2, 2+, and 3 will need a [custom hardware config file](https://github.com/PhotonVision/photonvision/tree/master/docs/source/docs/advanced-installation/sw_install/files) for lighting to work. Currently only limelight 2 and 2+ files are available.
:::
:::{note}
Raspberry Pi installations may also use the [Raspberry Pi Imager](https://www.raspberrypi.com/software/) to flash the image.
:::

View File

@@ -0,0 +1,93 @@
# Wiring
## 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.
2. Run an ethernet cable from your Pi to your network switch / radio.
This diagram shows how to use the recommended regulator to power a coprocessor.
::::{tab-set}
:::{tab-item} Orange Pi Zinc V USB C
```{image} images/OrangePiZincUSBC.png
:alt: Wiring the opi5 to the pdp using the Redux Robotics Zinc V and usb c
```
:::
:::{tab-item} Orange Pi 5 Zinc V
```{image} images/OrangePiZinc.png
:alt: Wiring the opi5 to the pdp using the Redux Robotics Zinc V
```
:::
:::{tab-item} Orange Pi 5 Pololu S13V30F5
```{image} images/OrangePiPololu.png
:alt: Wiring the opi5 to the pdp using the Pololu S13V30F5
```
:::
:::{tab-item} Orange Pi 5 Pololu S13V30F5 Pigtail
```{image} images/OrangePiPololuPigtail.png
:alt: Wiring the opi5 to the pdp using the Pololu S13V30F5 and a usb c pigtail
```
:::
:::{tab-item} Raspberry Pi 5 Zinc V USB C
```{image} images/RPiZincUSBC.png
:alt: Wiring the RPI5 to the pdp using the Redux Robotics Zinc V and usb c
```
:::
:::{tab-item} Raspberry Pi 5 Zinc V
```{image} images/RPiZinc.png
:alt: Wiring the RPI5 to the pdp using the Redux Robotics Zinc V
```
:::
:::{tab-item} Raspberry Pi 5 Pololu S13V30F5
```{image} images/RPiPololu.png
:alt: Wiring the RPI5 to the pdp using the Pololu S13V30F5
```
:::
:::{tab-item} Raspberry Pi 5 Pololu S13V30F5 Pigtail
```{image} images/RPiPololuPigtail.png
:alt: Wiring the RPI5 to the pdp using the Pololu S13V30F5 and a usb c pigtail
```
:::
::::
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)
## Coprocessor with Passive POE (Pi with SnakeEyes and Limelight)
1. Plug the [passive POE injector](https://www.revrobotics.com/rev-11-1210/) into the coprocessor and wire it to PDP/PDH (NOT the VRM).
2. Add a breaker to relevant slot in your PDP/PDH
3. Run an ethernet cable from the passive POE injector to your network switch / radio.
## Off-Robot Wiring
Plugging your coprocessor into the wall via a power brick will suffice for off robot wiring.
:::{note}
Please make sure your chosen power supply can provide enough power for your coprocessor. Undervolting (where enough power isn't being supplied) can cause many issues.
:::

View File

@@ -17,6 +17,6 @@ If solvePNP is working correctly, the target should be displayed as a small rect
</video>
```
## Contour Simplification (Non-Apriltag)
## Contour Simplification (Non-AprilTag)
3D mode internally computes a polygon that approximates the target contour being tracked. This polygon is used to detect the extreme corners of the target. The contour simplification slider changes how far from the original contour the approximation is allowed to deviate. Note that the approximate polygon is drawn on the output image for tuning.

View File

@@ -2,7 +2,7 @@
Hardware in the loop simulation is using a physical device, such as a supported co-processor running PhotonVision, to enhance simulation capabilities. This is useful for developing and validating code before the camera is attached to a robot, as well as reducing the work required to use WPILib simulation with PhotonVision.
Before continuing, ensure PhotonVision is installed on your device. Instructions can be found {ref}`here <docs/installation/index:Installation & Setup>` for all devices.
Before continuing, ensure PhotonVision is installed on your device. Instructions can be found {ref}`here <docs/advanced-installation/index:Advanced Installation>` for all devices.
Your coprocessor and computer running simulation will have to be connected to the same network, like a home router. Connecting the coprocessor directly to the computer will not work.
@@ -26,9 +26,11 @@ Ethernet adapter Ethernet:
Subnet Mask . . . . . . . . . . . : 255.255.255.0
Default Gateway . . . . . . . . . : 192.168.254.254
```
:::
```{image} images/coproc-client-to-desktop-sim.png
```
No code changes are required, PhotonLib should function similarly to normal operation.
@@ -36,4 +38,5 @@ No code changes are required, PhotonLib should function similarly to normal oper
Now launch simulation, and you should be able to see the PhotonVision table on your simulation's NetworkTables dashboard.
```{image} images/hardware-in-the-loop-sim.png
```

View File

@@ -1,6 +1,5 @@
# Simulation Support in PhotonLib in Java
## What Is Simulated?
Simulation is a powerful tool for validating robot code without access to a physical robot. Read more about [simulation in WPILib](https://docs.wpilib.org/en/stable/docs/software/wpilib-tools/robot-simulation/introduction.html).
@@ -8,18 +7,18 @@ Simulation is a powerful tool for validating robot code without access to a phys
In Java, PhotonLib can simulate cameras on the field and generate target data approximating what would be seen in reality. This simulation attempts to include the following:
- Camera Properties
- Field of Vision
- Lens distortion
- Image noise
- Framerate
- Latency
- Field of Vision
- Lens distortion
- Image noise
- Framerate
- Latency
- Target Data
- Detected / minimum-area-rectangle corners
- Center yaw/pitch
- Contour image area percentage
- Fiducial ID
- Fiducial ambiguity
- Fiducial solvePNP transform estimation
- Detected / minimum-area-rectangle corners
- Center yaw/pitch
- Contour image area percentage
- Fiducial ID
- Fiducial ambiguity
- Fiducial solvePNP transform estimation
- Camera Raw/Processed Streams (grayscale)
:::{note}
@@ -29,7 +28,7 @@ Simulation does NOT include the following:
- Image Thresholding Process (camera gain, brightness, etc)
- Pipeline switching
- Snapshots
:::
:::
This scope was chosen to balance fidelity of the simulation with the ease of setup, in a way that would best benefit most teams.
@@ -226,7 +225,7 @@ Each `VisionSystemSim` has its own built-in `Field2d` for displaying object pose
```
:::{figure} images/SimExampleField.png
*A* `VisionSystemSim`*'s internal* `Field2d` *customized with target images and colors*
_A_ `VisionSystemSim`_'s internal_ `Field2d` _customized with target images and colors_
:::
A `PhotonCameraSim` can also draw and publish generated camera frames to a MJPEG stream similar to an actual PhotonVision process.
@@ -245,8 +244,8 @@ A `PhotonCameraSim` can also draw and publish generated camera frames to a MJPEG
cameraSim.enableDrawWireframe(true);
```
These streams follow the port order mentioned in {ref}`docs/installation/networking:Camera Stream Ports`. For example, a single simulated camera will have its raw stream at `localhost:1181` and processed stream at `localhost:1182`, which can also be found in the CameraServer tab of Shuffleboard like a normal camera stream.
These streams follow the port order mentioned in {ref}`docs/quick-start/networking:Camera Stream Ports`. For example, a single simulated camera will have its raw stream at `localhost:1181` and processed stream at `localhost:1182`, which can also be found in the CameraServer tab of Shuffleboard like a normal camera stream.
:::{figure} images/SimExampleFrame.png
*A frame from the processed stream of a simulated camera viewing some 2023 AprilTags with the field wireframe enabled*
_A frame from the processed stream of a simulated camera viewing some 2023 AprilTags with the field wireframe enabled_
:::

View File

@@ -26,7 +26,7 @@ Please refer to our comprehensive {ref}`networking troubleshooting tips <docs/tr
Try these steps to {ref}`troubleshoot your camera connection <docs/troubleshooting/camera-troubleshooting:Camera Troubleshooting>`.
If you are using a USB camera, it is possible your USB Camera isn't supported by CSCore and therefore won't work with PhotonVision. See {ref}`supported hardware page for more information <docs/hardware/selecting-hardware:Recommended Cameras>`, or the above Camera Troubleshooting page for more information on determining this locally.
If you are using a USB camera, it is possible your USB Camera isn't supported by CSCore and therefore won't work with PhotonVision.
### Camera is consistently returning incorrect values when in 3D mode

View File

@@ -1,24 +1,24 @@
# Networking Troubleshooting
Before reading further, ensure that you follow all the recommendations {ref}`in our networking section <docs/installation/networking:Physical Networking>`. You should follow these guidelines in order for PhotonVision to work properly; other networking setups are not officially supported.
Before reading further, ensure that you follow all the recommendations {ref}`in our networking section <docs/quick-start/networking:Physical Networking>`. You should follow these guidelines in order for PhotonVision to work properly; other networking setups are not officially supported.
## Checklist
A few issues make up the majority of support requests. Run through this checklist quickly to catch some common mistakes.
- Is your camera connected to the robot's radio through a {ref}`network switch <docs/installation/networking:Physical Networking>`?
- Ethernet straight from a laptop to a coprocessor will not work (most likely), due to the unreliability of link-local connections.
- 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.
- Is your camera connected to the robot's radio through a {ref}`network switch <docs/quick-start/networking:Physical Networking>`?
- Ethernet straight from a laptop to a coprocessor will not work (most likely), due to the unreliability of link-local connections.
- 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?
- Limelights 2/2+ and Gloworms should be flashed using the Limelight 2 image (eg, `photonvision-v2024.2.8-linuxarm64_limelight2.img.xz`).
- Limelights 3 should be flashed using the Limelight 3 image (eg, `photonvision-v2024.2.8-linuxarm64_limelight3.img.xz`).
- Raspberry Pi devices (including Pi 3, Pi 4, CM3 and CM4) should be flashed using the Raspberry Pi image (eg, `photonvision-v2024.2.8-linuxarm64_RaspberryPi.img.xz`).
- Orange Pi 5 devices should be flashed using the Orange Pi 5 image (eg, `photonvision-v2024.2.8-linuxarm64_orangepi5.img.xz`).
- Orange Pi 5+ devices should be flashed using the Orange Pi 5+ image (eg, `photonvision-v2024.2.8-linuxarm64_orangepi5plus.img.xz`).
- Limelights 2/2+ should be flashed using the Limelight 2 image (eg, `photonvision-v2024.2.8-linuxarm64_limelight2.img.xz`).
- Limelights 3 should be flashed using the Limelight 3 image (eg, `photonvision-v2024.2.8-linuxarm64_limelight3.img.xz`).
- Raspberry Pi devices (including Pi 3, Pi 4, CM3 and CM4) should be flashed using the Raspberry Pi image (eg, `photonvision-v2024.2.8-linuxarm64_RaspberryPi.img.xz`).
- Orange Pi 5 devices should be flashed using the Orange Pi 5 image (eg, `photonvision-v2024.2.8-linuxarm64_orangepi5.img.xz`).
- Orange Pi 5+ devices should be flashed using the Orange Pi 5+ image (eg, `photonvision-v2024.2.8-linuxarm64_orangepi5plus.img.xz`).
- Is your robot code using a **2024** version of WPILib, and is your coprocessor using the most up to date **2024** release?
- 2022, 2023 and 2024 versions of either cannot be mix-and-matched!
- Your PhotonVision version can be checked on the {ref}`settings tab<docs/settings:settings>`.
- 2022, 2023 and 2024 versions of either cannot be mix-and-matched!
- Your PhotonVision version can be checked on the {ref}`settings tab<docs/settings:settings>`.
- Is your team number correctly set on the {ref}`settings tab<docs/settings:settings>`?
### photonvision.local Not Found

View File

@@ -2,28 +2,35 @@
:alt: PhotonVision
```
Welcome to the official documentation of PhotonVision! PhotonVision is the free, fast, and easy-to-use vision processing solution for the *FIRST* Robotics Competition. PhotonVision is designed to get vision working on your robot *quickly*, without the significant cost of other similar solutions. PhotonVision supports a variety of COTS hardware, including the Raspberry Pi 3 and 4, the [Gloworm smart camera](https://photonvision.github.io/gloworm-docs/docs/quickstart/#finding-gloworm), the [SnakeEyes Pi hat](https://www.playingwithfusion.com/productview.php?pdid=133), and the Orange Pi 5.
Welcome to the official documentation of PhotonVision! PhotonVision is the free, fast, and easy-to-use vision processing solution for the _FIRST_ Robotics Competition. PhotonVision is designed to get vision working on your robot _quickly_, without the significant cost of other similar solutions. PhotonVision supports a variety of COTS hardware, including the Raspberry Pi 3, 4, and 5, the [SnakeEyes Pi hat](https://www.playingwithfusion.com/productview.php?pdid=133), and the Orange Pi 5.
# Content
```{eval-rst}
.. grid:: 2
.. grid-item-card:: Getting Started
:link: docs/installation/index
.. grid-item-card:: Quick Start
:link: docs/quick-start/index
:link-type: doc
Get started with installing PhotonVision, creating a pipeline, and tuning it for usage in competitions.
Quick start to using Photonvision.
.. grid-item-card:: Advanced Installation
:link: docs/advanced-installation/index
:link-type: doc
Get started with installing PhotonVision on non-supported hardware.
```
```{eval-rst}
.. grid:: 2
.. grid-item-card:: Programming Reference and PhotonLib
:link: docs/programming/index
:link-type: doc
Learn more about PhotonLib, our vendor dependency which makes it easier for teams to retrieve vision data, make various calculations, and more.
```
```{eval-rst}
.. grid:: 2
.. grid-item-card:: Integration
:link: docs/integration/index
@@ -31,21 +38,26 @@ Welcome to the official documentation of PhotonVision! PhotonVision is the free,
Pick how to use vision processing results to control a physical robot.
```
```{eval-rst}
.. grid:: 2
.. grid-item-card:: Code Examples
:link: docs/examples/index
:link-type: doc
View various step by step guides on how to use data from PhotonVision in your code, along with game-specific examples.
```
```{eval-rst}
.. grid:: 2
.. grid-item-card:: Hardware
:link: docs/hardware/index
:link-type: doc
Select appropriate hardware for high-quality and easy vision target detection.
```
```{eval-rst}
.. grid:: 2
.. grid-item-card:: Contributing
:link: docs/contributing/index
@@ -77,8 +89,9 @@ PhotonVision is licensed under the [GNU GPL v3](https://www.gnu.org/licenses/gpl
:maxdepth: 0
docs/description
docs/quick-start/index
docs/hardware/index
docs/installation/index
docs/advanced-installation/index
docs/settings
```

View File

@@ -82,6 +82,11 @@ const boardType = ref<CalibrationBoardTypes>(CalibrationBoardTypes.Charuco);
const useOldPattern = ref(false);
const tagFamily = ref<CalibrationTagFamilies>(CalibrationTagFamilies.Dict_4X4_1000);
// Emperical testing - with stack size limit of 1MB, we can handle at -least- 700k points
const tooManyPoints = computed(
() => useStateStore().calibrationData.imageCount * patternWidth.value * patternHeight.value > 700000
);
const downloadCalibBoard = () => {
const doc = new JsPDF({ unit: "in", format: "letter" });
@@ -413,12 +418,17 @@ const setSelectedVideoFormat = (format: VideoFormat) => {
</v-col>
</v-row>
<v-row>
<v-col v-if="tooManyPoints" :cols="12">
<v-banner rounded color="red" text-color="white" class="mt-3" icon="mdi-alert-circle-outline">
Too many corners - finish calibration now!
</v-banner>
</v-col>
<v-col :cols="6">
<v-btn
small
color="secondary"
style="width: 100%"
:disabled="!settingsValid"
:disabled="!settingsValid || tooManyPoints"
@click="isCalibrating ? useCameraSettingsStore().takeCalibrationSnapshot() : startCalibration()"
>
<v-icon left class="calib-btn-icon"> {{ isCalibrating ? "mdi-camera" : "mdi-flag-outline" }} </v-icon>

View File

@@ -11,5 +11,3 @@ photonvision/*
photonvision_config/*
photon-server/lib/*
photon-server/package-lock.json
src/main/java/org/photonvision/PhotonVersion.java

View File

@@ -18,7 +18,6 @@ def nativeTasks = wpilibTools.createExtractionTasks {
nativeTasks.addToSourceSetResources(sourceSets.main)
nativeConfig.dependencies.add wpilibTools.deps.wpilib("wpilibc")
nativeConfig.dependencies.add wpilibTools.deps.wpilib("wpimath")
nativeConfig.dependencies.add wpilibTools.deps.wpilib("wpinet")
nativeConfig.dependencies.add wpilibTools.deps.wpilib("wpiutil")
@@ -66,9 +65,13 @@ dependencies {
}
task writeCurrentVersion {
def versionFileIn = file("${rootDir}/shared/PhotonVersion.java.in")
writePhotonVersionFile(versionFileIn, Path.of("$projectDir", "src", "main", "java", "org", "photonvision", "PhotonVersion.java"),
versionString)
doLast {
def versionFileIn = file("${rootDir}/shared/PhotonVersion.java.in")
writePhotonVersionFile(versionFileIn, Path.of("$buildDir", "generated", "java", "org", "photonvision", "PhotonVersion.java"),
versionString)
}
}
// https://github.com/wpilibsuite/allwpilib/blob/main/wpilibj/build.gradle#L52
sourceSets.main.java.srcDir "${buildDir}/generated/java/"
build.dependsOn writeCurrentVersion
compileJava.dependsOn writeCurrentVersion

View File

@@ -129,8 +129,11 @@ public class TimeSyncManager {
var conns = ntInstance.getConnections();
if (conns.length > 0) {
logger.debug("Changing TimeSyncClient server to " + conns[0].remote_ip);
m_client.setServer(conns[0].remote_ip);
var newServer = conns[0].remote_ip;
if (!m_client.getServer().equals(newServer)) {
logger.debug("Changing TimeSyncClient server to " + newServer);
m_client.setServer(newServer);
}
}
if (m_client != null) {

View File

@@ -33,7 +33,7 @@ model {
sources {
cpp {
source {
srcDirs 'src/main/native/cpp', "$buildDir/generated/source/proto/main/cpp", "src/generate/native/cpp"
srcDirs 'src/main/native/cpp', "$buildDir/generated/source/proto/main/cpp", "$buildDir/generated/native/cpp"
include '**/*.cpp', '**/*.cc'
}
exportedHeaders {
@@ -88,6 +88,7 @@ model {
}
if(project.hasProperty('includePhotonTargeting')) {
lib project: ':photon-targeting', library: 'photontargeting', linkage: 'shared'
lib project: ':photon-targeting', library: 'photontargetingJNI', linkage: 'shared'
}
}
@@ -160,14 +161,12 @@ task generateVendorJson() {
def read = photonlibFileInput.text
.replace('${photon_version}', pubVersion)
.replace('${frc_year}', frcYear)
.replace('${wpilib_version}', wpilibVersion)
photonlibFileOutput.text = read
outputs.upToDateWhen { false }
}
build.mustRunAfter generateVendorJson
publish.mustRunAfter generateVendorJson
build.dependsOn generateVendorJson
task publishVendorJsonToLocalOutputs(type: Copy) {
from photonlibFileOutput
@@ -181,17 +180,69 @@ task publishVendorJsonToLocalOutputs(type: Copy) {
publish.dependsOn it
}
task writeCurrentVersion {
def versionFileIn = file("${rootDir}/shared/PhotonVersion.java.in")
writePhotonVersionFile(versionFileIn, Path.of("$projectDir", "src", "main", "java", "org", "photonvision", "PhotonVersion.java"),
versionString)
versionFileIn = file("${rootDir}/shared/PhotonVersion.cpp.in")
writePhotonVersionFile(versionFileIn, Path.of("$projectDir", "src", "generate", "native", "cpp", "PhotonVersion.cpp"),
versionString)
task copyVendorJsonToExamples {
outputs.upToDateWhen { false }
jar.finalizedBy it
}
build.mustRunAfter writeCurrentVersion
cppHeadersZip.dependsOn writeCurrentVersion
[
"photonlib-cpp-examples",
"photonlib-java-examples"
].each { exampleFolder ->
file("${rootDir}/${exampleFolder}")
.listFiles()
.findAll {
return (it.isDirectory()
&& !it.isHidden()
&& !it.name.startsWith(".")
&& it.toPath().resolve("build.gradle").toFile().exists())
}
.collect { it.name }
.each { exampleVendordepFolder ->
task "copyVendorJsonTo${exampleFolder}-${exampleVendordepFolder}"(type: Copy) {
from photonlibFileOutput
into "${rootDir}/${exampleFolder}/${exampleVendordepFolder}/vendordeps/"
outputs.upToDateWhen { false }
copyVendorJsonToExamples.dependsOn it
}
}
}
clean {
[
"photonlib-cpp-examples",
"photonlib-java-examples"
].each { exampleFolder ->
file("${rootDir}/${exampleFolder}")
.listFiles()
.findAll {
return (it.isDirectory()
&& !it.isHidden()
&& !it.name.startsWith(".")
&& it.toPath().resolve("build.gradle").toFile().exists())
}
.collect { it.name }
.each { exampleVendordepFolder ->
delete "${rootDir}/${exampleFolder}/${exampleVendordepFolder}/vendordeps/"
}
}
}
task writeCurrentVersion {
doLast {
def versionFileIn = file("${rootDir}/shared/PhotonVersion.java.in")
writePhotonVersionFile(versionFileIn, Path.of("$buildDir", "generated", "java", "org", "photonvision", "PhotonVersion.java"),
versionString)
versionFileIn = file("${rootDir}/shared/PhotonVersion.cpp.in")
writePhotonVersionFile(versionFileIn, Path.of("$buildDir", "generated", "native", "cpp", "PhotonVersion.cpp"),
versionString)
}
}
// https://github.com/wpilibsuite/allwpilib/blob/main/wpilibj/build.gradle#L52
sourceSets.main.java.srcDir "${buildDir}/generated/java/"
compileJava.dependsOn writeCurrentVersion
// Building photon-lib requires photon-targeting to generate its proto files. This technically shouldn't be required but is needed for it to build.
model {
@@ -205,6 +256,7 @@ model {
}
it.binaries.all {
it.tasks.withType(CppCompile) {
it.dependsOn writeCurrentVersion
it.dependsOn ":photon-targeting:generateProto"
}
}
@@ -242,7 +294,7 @@ if (!project.hasProperty('copyOfflineArtifacts')) {
tasks.named('cppSourcesZip') {
dependsOn writeCurrentVersion
from("$projectDir/src/generate/native/cpp") {
from("$buildDir/generated/native/cpp") {
into '/'
}
}
@@ -251,7 +303,6 @@ tasks.named('cppSourcesZip') {
def zipBaseNameCombined = '_GROUP_org.photonvision_combinedcpp_ID_photonvision-combinedcpp_CLS'
task combinedCppSourcesZip(type: Zip) {
dependsOn(':photon-lib:cppSourcesZip', ':photon-targeting:cppSourcesZip')
mustRunAfter(':photon-lib:cppHeadersZip', ':photon-targeting:cppHeadersZip')
destinationDirectory = file("$buildDir/outputs")
archiveBaseName = zipBaseNameCombined
@@ -269,7 +320,6 @@ task combinedCppSourcesZip(type: Zip) {
}
task combinedHeadersZip(type: Zip) {
dependsOn(':photon-lib:cppHeadersZip', ':photon-targeting:cppHeadersZip')
mustRunAfter(':photon-lib:cppHeadersZip', ':photon-targeting:cppHeadersZip')
destinationDirectory = file("$buildDir/outputs")
archiveBaseName = zipBaseNameCombined
@@ -315,7 +365,6 @@ def nativeTasks = wpilibTools.createExtractionTasks {
nativeTasks.addToSourceSetResources(sourceSets.test)
nativeConfig.dependencies.add wpilibTools.deps.wpilib("wpilibc")
nativeConfig.dependencies.add wpilibTools.deps.wpilib("wpimath")
nativeConfig.dependencies.add wpilibTools.deps.wpilib("wpinet")
nativeConfig.dependencies.add wpilibTools.deps.wpilib("wpiutil")

View File

@@ -11,4 +11,4 @@ for f in dist/*.whl; do
done
# Run the test suite
pytest -rP --full-trace
pytest -rP

View File

@@ -1,5 +1,5 @@
import math
from typing import Any, Tuple
from typing import Any
import cv2 as cv
import numpy as np
@@ -27,6 +27,12 @@ class OpenCVHelp:
@staticmethod
def translationToTVec(translations: list[Translation3d]) -> np.ndarray:
"""Creates a new :class:`np.array` with these 3d translations. The opencv tvec is a vector with
three elements representing {x, y, z} in the EDN coordinate system.
:param translations: The translations to convert into a np.array
"""
retVal: list[list] = []
for translation in translations:
trl = OpenCVHelp.translationNWUtoEDN(translation)
@@ -38,6 +44,13 @@ class OpenCVHelp:
@staticmethod
def rotationToRVec(rotation: Rotation3d) -> np.ndarray:
"""Creates a new :class:`.np.array` with this 3d rotation. The opencv rvec Mat is a vector with
three elements representing the axis scaled by the angle in the EDN coordinate system. (angle =
norm, and axis = rvec / norm)
:param rotation: The rotation to convert into a np.array
"""
retVal: list[np.ndarray] = []
rot = OpenCVHelp.rotationNWUtoEDN(rotation)
rotVec = rot.getQuaternion().toRotationVector()
@@ -48,13 +61,13 @@ class OpenCVHelp:
)
@staticmethod
def avgPoint(points: list[Tuple[float, float]]) -> Tuple[float, float]:
def avgPoint(points: np.ndarray) -> np.ndarray:
x = 0.0
y = 0.0
for p in points:
x += p[0]
y += p[1]
return (x / len(points), y / len(points))
x += p[0, 0]
y += p[0, 1]
return np.array([[x / len(points), y / len(points)]])
@staticmethod
def pointsToTargetCorners(points: np.ndarray) -> list[TargetCorner]:
@@ -88,6 +101,25 @@ class OpenCVHelp:
def reorderCircular(
elements: list[Any] | np.ndarray, backwards: bool, shiftStart: int
) -> list[Any]:
"""Reorders the list, optionally indexing backwards and wrapping around to the last element after
the first, and shifting all indices in the direction of indexing.
e.g.
({1,2,3}, false, 1) == {2,3,1}
({1,2,3}, true, 0) == {1,3,2}
({1,2,3}, true, 1) == {3,2,1}
:param elements: list elements
:param backwards: If indexing should happen in reverse (0, size-1, size-2, ...)
:param shiftStart: How much the initial index should be shifted (instead of starting at index 0,
start at shiftStart, negated if backwards)
:returns: Reordered list
"""
size = len(elements)
reordered = []
dir = -1 if backwards else 1
@@ -100,18 +132,39 @@ class OpenCVHelp:
@staticmethod
def translationEDNToNWU(trl: Translation3d) -> Translation3d:
"""Convert a rotation delta from EDN to NWU. For example, if you have a rotation X,Y,Z {1, 0, 0}
in EDN, this would be {0, -1, 0} in NWU.
"""
return trl.rotateBy(EDN_TO_NWU)
@staticmethod
def rotationEDNToNWU(rot: Rotation3d) -> Rotation3d:
"""Convert a rotation delta from NWU to EDN. For example, if you have a rotation X,Y,Z {1, 0, 0}
in NWU, this would be {0, 0, 1} in EDN.
"""
return -EDN_TO_NWU + (rot + EDN_TO_NWU)
@staticmethod
def tVecToTranslation(tvecInput: np.ndarray) -> Translation3d:
"""Returns a new 3d translation from this :class:`.Mat`. The opencv tvec is a vector with three
elements representing {x, y, z} in the EDN coordinate system.
:param tvecInput: The tvec to create a Translation3d from
"""
return OpenCVHelp.translationEDNToNWU(Translation3d(tvecInput))
@staticmethod
def rVecToRotation(rvecInput: np.ndarray) -> Rotation3d:
"""Returns a 3d rotation from this :class:`.Mat`. The opencv rvec Mat is a vector with three
elements representing the axis scaled by the angle in the EDN coordinate system. (angle = norm,
and axis = rvec / norm)
:param rvecInput: The rvec to create a Rotation3d from
"""
return OpenCVHelp.rotationEDNToNWU(Rotation3d(rvecInput))
@staticmethod
@@ -121,12 +174,43 @@ class OpenCVHelp:
modelTrls: list[Translation3d],
imagePoints: np.ndarray,
) -> PnpResult | None:
"""Finds the transformation(s) that map the camera's pose to the target's pose. The camera's pose
relative to the target is determined by the supplied 3d points of the target's model and their
associated 2d points imaged by the camera. The supplied model translations must be relative to
the target's pose.
For planar targets, there may be an alternate solution which is plausible given the 2d image
points. This has an associated "ambiguity" which describes the ratio of reprojection error
between the "best" and "alternate" solution.
This method is intended for use with individual AprilTags, and will not work unless 4 points
are provided.
:param cameraMatrix: The camera intrinsics matrix in standard opencv form
:param distCoeffs: The camera distortion matrix in standard opencv form
:param modelTrls: The translations of the object corners. These should have the object pose as
their origin. These must come in a specific, pose-relative order (in NWU):
- Point 0: [0, -squareLength / 2, squareLength / 2]
- Point 1: [0, squareLength / 2, squareLength / 2]
- Point 2: [0, squareLength / 2, -squareLength / 2]
- Point 3: [0, -squareLength / 2, -squareLength / 2]
:param imagePoints: The projection of these 3d object points into the 2d camera image. The order
should match the given object point translations.
:returns: The resulting transformation that maps the camera pose to the target pose and the
ambiguity if an alternate solution is available.
"""
modelTrls = OpenCVHelp.reorderCircular(modelTrls, True, -1)
imagePoints = np.array(OpenCVHelp.reorderCircular(imagePoints, True, -1))
objectMat = np.array(OpenCVHelp.translationToTVec(modelTrls))
alt: Transform3d | None = None
reprojectionError: cv.typing.MatLike | None = None
best: Transform3d = Transform3d()
for tries in range(2):
# calc rvecs/tvecs and associated reprojection error from image points
retval, rvecs, tvecs, reprojectionError = cv.solvePnPGeneric(
objectMat,
imagePoints,
@@ -135,6 +219,7 @@ class OpenCVHelp:
flags=cv.SOLVEPNP_IPPE_SQUARE,
)
# convert to wpilib coordinates
best = Transform3d(
OpenCVHelp.tVecToTranslation(tvecs[0]),
OpenCVHelp.rVecToRotation(rvecs[0]),
@@ -145,7 +230,10 @@ class OpenCVHelp:
OpenCVHelp.rVecToRotation(rvecs[1]),
)
if not math.isnan(reprojectionError[0, 0]):
# check if we got a NaN result
if reprojectionError is not None and not math.isnan(
reprojectionError[0, 0]
):
break
else:
pt = imagePoints[0]
@@ -153,7 +241,8 @@ class OpenCVHelp:
pt[0, 1] -= 0.001
imagePoints[0] = pt
if math.isnan(reprojectionError[0, 0]):
# solvePnP failed
if reprojectionError is None or math.isnan(reprojectionError[0, 0]):
print("SolvePNP_Square failed!")
return None
@@ -181,6 +270,27 @@ class OpenCVHelp:
modelTrls: list[Translation3d],
imagePoints: np.ndarray,
) -> PnpResult | None:
"""Finds the transformation that maps the camera's pose to the origin of the supplied object. An
"object" is simply a set of known 3d translations that correspond to the given 2d points. If,
for example, the object translations are given relative to close-right corner of the blue
alliance(the default origin), a camera-to-origin transformation is returned. If the
translations are relative to a target's pose, a camera-to-target transformation is returned.
There must be at least 3 points to use this method. This does not return an alternate
solution-- if you are intending to use solvePNP on a single AprilTag, see {@link
#solvePNP_SQUARE} instead.
:param cameraMatrix: The camera intrinsics matrix in standard opencv form
:param distCoeffs: The camera distortion matrix in standard opencv form
:param objectTrls: The translations of the object corners, relative to the field.
:param imagePoints: The projection of these 3d object points into the 2d camera image. The order
should match the given object point translations.
:returns: The resulting transformation that maps the camera pose to the target pose. If the 3d
model points are supplied relative to the origin, this transformation brings the camera to
the origin.
"""
objectMat = np.array(OpenCVHelp.translationToTVec(modelTrls))
retval, rvecs, tvecs, reprojectionError = cv.solvePnPGeneric(

View File

@@ -4,29 +4,71 @@ from wpimath.geometry import Pose3d, Rotation3d, Transform3d, Translation3d
class RotTrlTransform3d:
"""Represents a transformation that first rotates a pose around the origin, and then translates it."""
def __init__(
self, rot: Rotation3d = Rotation3d(), trl: Translation3d = Translation3d()
):
"""A rotation-translation transformation.
Applying this RotTrlTransform3d to poses will preserve their current origin-to-pose
transform as if the origin was transformed by these components instead.
:param rot: The rotation component
:param trl: The translation component
"""
self.rot = rot
self.trl = trl
def inverse(self) -> Self:
"""The inverse of this transformation. Applying the inverse will "undo" this transformation."""
invRot = -self.rot
invTrl = -(self.trl.rotateBy(invRot))
return type(self)(invRot, invTrl)
def getTransform(self) -> Transform3d:
"""This transformation as a Transform3d (as if of the origin)"""
return Transform3d(self.trl, self.rot)
def getTranslation(self) -> Translation3d:
"""The translation component of this transformation"""
return self.trl
def getRotation(self) -> Rotation3d:
"""The rotation component of this transformation"""
return self.rot
def apply(self, trlToApply: Translation3d) -> Translation3d:
def applyTranslation(self, trlToApply: Translation3d) -> Translation3d:
return trlToApply.rotateBy(self.rot) + self.trl
def applyRotation(self, rotToApply: Rotation3d) -> Rotation3d:
return rotToApply + self.rot
def applyPose(self, poseToApply: Pose3d) -> Pose3d:
return Pose3d(
self.applyTranslation(poseToApply.translation()),
self.applyRotation(poseToApply.rotation()),
)
def applyTrls(self, rots: list[Rotation3d]) -> list[Rotation3d]:
retVal: list[Rotation3d] = []
for rot in rots:
retVal.append(self.applyRotation(rot))
return retVal
@classmethod
def makeRelativeTo(cls, pose: Pose3d) -> Self:
"""The rotation-translation transformation that makes poses in the world consider this pose as the
new origin, or change the basis to this pose.
:param pose: The new origin
"""
return cls(pose.rotation(), pose.translation()).inverse()
@classmethod
def makeBetweenPoses(cls, initial: Pose3d, last: Pose3d) -> Self:
return cls(
last.rotation() - initial.rotation(),
last.translation()
- initial.translation().rotateBy(last.rotation() - initial.rotation()),
)

View File

@@ -8,87 +8,117 @@ from . import RotTrlTransform3d
class TargetModel:
def __init__(
self,
*,
width: meters | None = None,
height: meters | None = None,
length: meters | None = None,
diameter: meters | None = None,
verts: List[Translation3d] | None = None
):
"""Describes the 3d model of a target."""
if (
width is not None
and height is not None
and length is None
and diameter is None
and verts is None
):
self.isPlanar = True
self.isSpherical = False
self.vertices = [
Translation3d(0.0, -width / 2.0, -height / 2.0),
Translation3d(0.0, width / 2.0, -height / 2.0),
Translation3d(0.0, width / 2.0, height / 2.0),
Translation3d(0.0, -width / 2.0, height / 2.0),
]
def __init__(self):
"""Default constructor for initialising internal class members. DO NOT USE THIS!!! USE THE createPlanar,
createCuboid, createSpheroid or create Arbitrary
"""
self.vertices: List[Translation3d] = []
self.isPlanar = False
self.isSpherical = False
return
@classmethod
def createPlanar(cls, width: meters, height: meters) -> Self:
"""Creates a rectangular, planar target model given the width and height. The model has four
vertices:
elif (
length is not None
and width is not None
and height is not None
and diameter is None
and verts is None
):
verts = [
Translation3d(length / 2.0, -width / 2.0, -height / 2.0),
Translation3d(length / 2.0, width / 2.0, -height / 2.0),
Translation3d(length / 2.0, width / 2.0, height / 2.0),
Translation3d(length / 2.0, -width / 2.0, height / 2.0),
Translation3d(-length / 2.0, -width / 2.0, height / 2.0),
Translation3d(-length / 2.0, width / 2.0, height / 2.0),
Translation3d(-length / 2.0, width / 2.0, -height / 2.0),
Translation3d(-length / 2.0, -width / 2.0, -height / 2.0),
]
# Handle the rest of this in the "default" case
elif (
diameter is not None
and width is None
and height is None
and length is None
and verts is None
):
self.isPlanar = False
self.isSpherical = True
self.vertices = [
Translation3d(0.0, -diameter / 2.0, 0.0),
Translation3d(0.0, 0.0, -diameter / 2.0),
Translation3d(0.0, diameter / 2.0, 0.0),
Translation3d(0.0, 0.0, diameter / 2.0),
]
return
elif (
verts is not None
and width is None
and height is None
and length is None
and diameter is None
):
# Handle this in the "default" case
pass
else:
raise Exception("Not a valid overload")
- Point 0: [0, -width/2, -height/2]
- Point 1: [0, width/2, -height/2]
- Point 2: [0, width/2, height/2]
- Point 3: [0, -width/2, height/2]
"""
# TODO maybe remove this if there is a better/preferred way
# make the python type checking gods happy
assert verts is not None
tm = cls()
tm.isPlanar = True
tm.isSpherical = False
tm.vertices = [
Translation3d(0.0, -width / 2.0, -height / 2.0),
Translation3d(0.0, width / 2.0, -height / 2.0),
Translation3d(0.0, width / 2.0, height / 2.0),
Translation3d(0.0, -width / 2.0, height / 2.0),
]
return tm
@classmethod
def createCuboid(cls, length: meters, width: meters, height: meters) -> Self:
"""Creates a cuboid target model given the length, width, height. The model has eight vertices:
- Point 0: [length/2, -width/2, -height/2]
- Point 1: [length/2, width/2, -height/2]
- Point 2: [length/2, width/2, height/2]
- Point 3: [length/2, -width/2, height/2]
- Point 4: [-length/2, -width/2, height/2]
- Point 5: [-length/2, width/2, height/2]
- Point 6: [-length/2, width/2, -height/2]
- Point 7: [-length/2, -width/2, -height/2]
"""
tm = cls()
verts = [
Translation3d(length / 2.0, -width / 2.0, -height / 2.0),
Translation3d(length / 2.0, width / 2.0, -height / 2.0),
Translation3d(length / 2.0, width / 2.0, height / 2.0),
Translation3d(length / 2.0, -width / 2.0, height / 2.0),
Translation3d(-length / 2.0, -width / 2.0, height / 2.0),
Translation3d(-length / 2.0, width / 2.0, height / 2.0),
Translation3d(-length / 2.0, width / 2.0, -height / 2.0),
Translation3d(-length / 2.0, -width / 2.0, -height / 2.0),
]
tm._common_construction(verts)
return tm
@classmethod
def createSpheroid(cls, diameter: meters) -> Self:
"""Creates a spherical target model which has similar dimensions regardless of its rotation. This
model has four vertices:
- Point 0: [0, -radius, 0]
- Point 1: [0, 0, -radius]
- Point 2: [0, radius, 0]
- Point 3: [0, 0, radius]
*Q: Why these vertices?* A: This target should be oriented to the camera every frame, much
like a sprite/decal, and these vertices represent the ellipse vertices (maxima). These vertices
are used for drawing the image of this sphere, but do not match the corners that will be
published by photonvision.
"""
tm = cls()
tm.isPlanar = False
tm.isSpherical = True
tm.vertices = [
Translation3d(0.0, -diameter / 2.0, 0.0),
Translation3d(0.0, 0.0, -diameter / 2.0),
Translation3d(0.0, diameter / 2.0, 0.0),
Translation3d(0.0, 0.0, diameter / 2.0),
]
return tm
@classmethod
def createArbitrary(cls, verts: List[Translation3d]) -> Self:
"""Creates a target model from arbitrary 3d vertices. Automatically determines if the given
vertices are planar(x == 0). More than 2 vertices must be given. If this is a planar model, the
vertices should define a non-intersecting contour.
:param vertices: Translations representing the vertices of this target model relative to its
pose.
"""
tm = cls()
tm._common_construction(verts)
return tm
def _common_construction(self, verts: List[Translation3d]) -> None:
self.isSpherical = False
if len(verts) <= 2:
self.vertices: List[Translation3d] = []
self.vertices = []
self.isPlanar = False
else:
cornersPlaner = True
@@ -100,17 +130,33 @@ class TargetModel:
self.vertices = verts
def getFieldVertices(self, targetPose: Pose3d) -> List[Translation3d]:
"""This target's vertices offset from its field pose.
Note: If this target is spherical, use {@link #getOrientedPose(Translation3d,
Translation3d)} with this method.
"""
basisChange = RotTrlTransform3d(targetPose.rotation(), targetPose.translation())
retVal = []
for vert in self.vertices:
retVal.append(basisChange.apply(vert))
retVal.append(basisChange.applyTranslation(vert))
return retVal
@classmethod
def getOrientedPose(cls, tgtTrl: Translation3d, cameraTrl: Translation3d):
"""Returns a Pose3d with the given target translation oriented (with its relative x-axis aligned)
to the camera translation. This is used for spherical targets which should not have their
projection change regardless of their own rotation.
:param tgtTrl: This target's translation
:param cameraTrl: Camera's translation
:returns: This target's pose oriented to the camera
"""
relCam = cameraTrl - tgtTrl
orientToCam = Rotation3d(
0.0,
@@ -130,8 +176,8 @@ class TargetModel:
@classmethod
def AprilTag36h11(cls) -> Self:
return cls(width=6.5 * 0.0254, height=6.5 * 0.0254)
return cls.createPlanar(width=6.5 * 0.0254, height=6.5 * 0.0254)
@classmethod
def AprilTag16h5(cls) -> Self:
return cls(width=6.0 * 0.0254, height=6.0 * 0.0254)
return cls.createPlanar(width=6.0 * 0.0254, height=6.0 * 0.0254)

View File

@@ -11,15 +11,16 @@ class VisionEstimation:
def getVisibleLayoutTags(
visTags: list[PhotonTrackedTarget], layout: AprilTagFieldLayout
) -> list[AprilTag]:
"""Get the visible :class:`.AprilTag`s which are in the tag layout using the visible tag IDs."""
retVal: list[AprilTag] = []
for tag in visTags:
id = tag.getFiducialId()
maybePose = layout.getTagPose(id)
if maybePose:
tag = AprilTag()
tag.ID = id
tag.pose = maybePose
retVal.append(tag)
aprilTag = AprilTag()
aprilTag.ID = id
aprilTag.pose = maybePose
retVal.append(aprilTag)
return retVal
@staticmethod
@@ -30,12 +31,31 @@ class VisionEstimation:
layout: AprilTagFieldLayout,
tagModel: TargetModel,
) -> PnpResult | None:
"""Performs solvePNP using 3d-2d point correspondences of visible AprilTags to estimate the
field-to-camera transformation. If only one tag is visible, the result may have an alternate
solution.
**Note:** The returned transformation is from the field origin to the camera pose!
With only one tag: {@link OpenCVHelp#solvePNP_SQUARE}
With multiple tags: {@link OpenCVHelp#solvePNP_SQPNP}
:param cameraMatrix: The camera intrinsics matrix in standard opencv form
:param distCoeffs: The camera distortion matrix in standard opencv form
:param visTags: The visible tags reported by PV. Non-tag targets are automatically excluded.
:param tagLayout: The known tag layout on the field
:returns: The transformation that maps the field origin to the camera pose. Ensure the {@link
PnpResult} are present before utilizing them.
"""
if len(visTags) == 0:
return None
corners: list[TargetCorner] = []
knownTags: list[AprilTag] = []
# ensure these are AprilTags in our layout
for tgt in visTags:
id = tgt.getFiducialId()
maybePose = layout.getTagPose(id)
@@ -53,6 +73,7 @@ class VisionEstimation:
points = OpenCVHelp.cornersToPoints(corners)
# single-tag pnp
if len(knownTags) == 1:
camToTag = OpenCVHelp.solvePNP_Square(
cameraMatrix, distCoeffs, tagModel.getVertices(), points
@@ -74,6 +95,7 @@ class VisionEstimation:
altReprojErr=camToTag.altReprojErr,
)
return result
# multi-tag pnp
else:
objectTrls: list[Translation3d] = []
for tag in knownTags:

View File

@@ -20,8 +20,14 @@
## --> DO NOT MODIFY <--
###############################################################################
from typing import TYPE_CHECKING
from ..packet import Packet
from ..targeting import *
from ..targeting import * # noqa
if TYPE_CHECKING:
from ..targeting import MultiTargetPNPResult # noqa
from ..targeting import PnpResult # noqa
class MultiTargetPNPResultSerde:

View File

@@ -20,8 +20,13 @@
## --> DO NOT MODIFY <--
###############################################################################
from typing import TYPE_CHECKING
from ..packet import Packet
from ..targeting import *
from ..targeting import * # noqa
if TYPE_CHECKING:
from ..targeting import PhotonPipelineMetadata # noqa
class PhotonPipelineMetadataSerde:

View File

@@ -20,8 +20,16 @@
## --> DO NOT MODIFY <--
###############################################################################
from typing import TYPE_CHECKING
from ..packet import Packet
from ..targeting import *
from ..targeting import * # noqa
if TYPE_CHECKING:
from ..targeting import MultiTargetPNPResult # noqa
from ..targeting import PhotonPipelineMetadata # noqa
from ..targeting import PhotonPipelineResult # noqa
from ..targeting import PhotonTrackedTarget # noqa
class PhotonPipelineResultSerde:

View File

@@ -20,8 +20,14 @@
## --> DO NOT MODIFY <--
###############################################################################
from typing import TYPE_CHECKING
from ..packet import Packet
from ..targeting import *
from ..targeting import * # noqa
if TYPE_CHECKING:
from ..targeting import PhotonTrackedTarget # noqa
from ..targeting import TargetCorner # noqa
class PhotonTrackedTargetSerde:

View File

@@ -20,8 +20,13 @@
## --> DO NOT MODIFY <--
###############################################################################
from typing import TYPE_CHECKING
from ..packet import Packet
from ..targeting import *
from ..targeting import * # noqa
if TYPE_CHECKING:
from ..targeting import PnpResult # noqa
class PnpResultSerde:

View File

@@ -20,8 +20,13 @@
## --> DO NOT MODIFY <--
###############################################################################
from typing import TYPE_CHECKING
from ..packet import Packet
from ..targeting import *
from ..targeting import * # noqa
if TYPE_CHECKING:
from ..targeting import TargetCorner # noqa
class TargetCornerSerde:

View File

@@ -9,9 +9,18 @@ PhotonPipelineResult_TYPE_STRING = (
class NTTopicSet:
"""This class is a wrapper around all per-pipeline NT topics that PhotonVision should be publishing
It's split here so the sim and real-camera implementations can share a common implementation of
the naming and registration of the NT content.
def __init__(self) -> None:
self.subTable = nt.NetworkTableInstance.getDefault()
However, we do expect that the actual logic which fills out values in the entries will be
different for sim vs. real camera
"""
def __init__(self, tableName: str, cameraName: str) -> None:
instance = nt.NetworkTableInstance.getDefault()
photonvision_root_table = instance.getTable(tableName)
self.subTable = photonvision_root_table.getSubTable(cameraName)
def updateEntries(self) -> None:
options = nt.PubSubOptions()

View File

@@ -48,6 +48,10 @@ def setVersionCheckEnabled(enabled: bool):
class PhotonCamera:
def __init__(self, cameraName: str):
"""Constructs a PhotonCamera from the name of the camera.
:param cameraName: The nickname of the camera (found in the PhotonVision UI).
"""
instance = ntcore.NetworkTableInstance.getDefault()
self._name = cameraName
self._tableName = "photonvision"
@@ -132,6 +136,14 @@ class PhotonCamera:
return ret
def getLatestResult(self) -> PhotonPipelineResult:
"""Returns the latest pipeline result. This is simply the most recent result Received via NT.
Calling this multiple times will always return the most recent result.
Replaced by :meth:`.getAllUnreadResults` over getLatestResult, as this function can miss
results, or provide duplicate ones!
TODO implement the thing that will take this ones place...
"""
self._versionCheck()
now = RobotController.getFPGATime()
@@ -149,34 +161,85 @@ class PhotonCamera:
return retVal
def getDriverMode(self) -> bool:
"""Returns whether the camera is in driver mode.
:returns: Whether the camera is in driver mode.
"""
return self._driverModeSubscriber.get()
def setDriverMode(self, driverMode: bool) -> None:
"""Toggles driver mode.
:param driverMode: Whether to set driver mode.
"""
self._driverModePublisher.set(driverMode)
def takeInputSnapshot(self) -> None:
"""Request the camera to save a new image file from the input camera stream with overlays. Images
take up space in the filesystem of the PhotonCamera. Calling it frequently will fill up disk
space and eventually cause the system to stop working. Clear out images in
/opt/photonvision/photonvision_config/imgSaves frequently to prevent issues.
"""
self._inputSaveImgEntry.set(self._inputSaveImgEntry.get() + 1)
def takeOutputSnapshot(self) -> None:
"""Request the camera to save a new image file from the output stream with overlays. Images take
up space in the filesystem of the PhotonCamera. Calling it frequently will fill up disk space
and eventually cause the system to stop working. Clear out images in
/opt/photonvision/photonvision_config/imgSaves frequently to prevent issues.
"""
self._outputSaveImgEntry.set(self._outputSaveImgEntry.get() + 1)
def getPipelineIndex(self) -> int:
"""Returns the active pipeline index.
:returns: The active pipeline index.
"""
return self._pipelineIndexState.get(0)
def setPipelineIndex(self, index: int) -> None:
"""Allows the user to select the active pipeline index.
:param index: The active pipeline index.
"""
self._pipelineIndexRequest.set(index)
def getLEDMode(self) -> VisionLEDMode:
"""Returns the current LED mode.
:returns: The current LED mode.
"""
mode = self._ledModeState.get()
return VisionLEDMode(mode)
def setLEDMode(self, led: VisionLEDMode) -> None:
"""Sets the LED mode.
:param led: The mode to set to.
"""
self._ledModeRequest.set(led.value)
def getName(self) -> str:
"""Returns the name of the camera. This will return the same value that was given to the
constructor as cameraName.
:returns: The name of the camera.
"""
return self._name
def isConnected(self) -> bool:
"""Returns whether the camera is connected and actively returning new data. Connection status is
debounced.
:returns: True if the camera is actively sending frame data, false otherwise.
"""
curHeartbeat = self._heartbeatEntry.get()
now = Timer.getFPGATimestamp()
@@ -197,6 +260,8 @@ class PhotonCamera:
_lastVersionTimeCheck = Timer.getFPGATimestamp()
# Heartbeat entry is assumed to always be present. If it's not present, we
# assume that a camera with that name was never connected in the first place.
if not self._heartbeatEntry.exists():
cameraNames = (
self._cameraTable.getInstance().getTable(self._tableName).getSubTables()
@@ -222,6 +287,7 @@ class PhotonCamera:
True,
)
# Check for connection status. Warn if disconnected.
elif not self.isConnected():
wpilib.reportWarning(
f"PhotonVision coprocessor at path {self._path} is not sending new data.",
@@ -229,9 +295,10 @@ class PhotonCamera:
)
versionString = self.versionEntry.get(defaultValue="")
localUUID = PhotonPipelineResult.photonStruct.MESSAGE_VERSION
remoteUUID = self._rawBytesEntry.getTopic().getProperty("message_uuid")
# Check mdef UUID
localUUID = PhotonPipelineResult.photonStruct.MESSAGE_VERSION
remoteUUID = str(self._rawBytesEntry.getTopic().getProperty("message_uuid"))
if not remoteUUID:
wpilib.reportWarning(

View File

@@ -4,8 +4,8 @@ import typing
import cscore as cs
import cv2 as cv
import numpy as np
import robotpy_apriltag
import wpilib
from robotpy_apriltag import AprilTagField, AprilTagFieldLayout
from wpimath.geometry import Pose3d, Transform3d
from wpimath.units import meters, seconds
@@ -26,52 +26,46 @@ from .visionTargetSim import VisionTargetSim
class PhotonCameraSim:
"""A handle for simulating :class:`.PhotonCamera` values. Processing simulated targets through this
class will change the associated PhotonCamera's results.
"""
kDefaultMinAreaPx: float = 100
def __init__(
self,
camera: PhotonCamera,
props: SimCameraProperties | None = None,
props: SimCameraProperties = SimCameraProperties.PERFECT_90DEG(),
minTargetAreaPercent: float | None = None,
maxSightRange: meters | None = None,
):
"""Constructs a handle for simulating :class:`.PhotonCamera` values. Processing simulated targets
through this class will change the associated PhotonCamera's results.
By default, this constructor's camera has a 90 deg FOV with no simulated lag if props!
By default, the minimum target area is 100 pixels and there is no maximum sight range unless both params are passed to override.
:param camera: The camera to be simulated
:param prop: Properties of this camera such as FOV and FPS
:param minTargetAreaPercent: The minimum percentage(0 - 100) a detected target must take up of
the camera's image to be processed. Match this with your contour filtering settings in the
PhotonVision GUI.
:param maxSightRangeMeters: Maximum distance at which the target is illuminated to your camera.
Note that minimum target area of the image is separate from this.
"""
self.minTargetAreaPercent: float = 0.0
self.maxSightRange: float = 1.0e99
self.videoSimRawEnabled: bool = False
self.videoSimWireframeEnabled: bool = False
self.videoSimWireframeResolution: float = 0.1
self.videoSimProcEnabled: bool = True
self.ts = NTTopicSet()
self.videoSimProcEnabled: bool = (
False # TODO switch this back to default True when the functionality is enabled
)
self.heartbeatCounter: int = 0
self.nextNtEntryTime = int(wpilib.Timer.getFPGATimestamp() * 1e6)
self.tagLayout = robotpy_apriltag.loadAprilTagLayoutField(
robotpy_apriltag.AprilTagField.k2024Crescendo
)
if (
camera is not None
and props is None
and minTargetAreaPercent is None
and maxSightRange is None
):
props = SimCameraProperties.PERFECT_90DEG()
elif (
camera is not None
and props is not None
and minTargetAreaPercent is not None
and maxSightRange is not None
):
pass
elif (
camera is not None
and props is not None
and minTargetAreaPercent is None
and maxSightRange is None
):
pass
else:
raise Exception("Invalid Constructor Called")
self.tagLayout = AprilTagFieldLayout.loadField(AprilTagField.k2024Crescendo)
self.cam = camera
self.prop = props
@@ -101,16 +95,11 @@ class PhotonCameraSim:
(self.prop.getResWidth(), self.prop.getResHeight())
)
self.ts.subTable = self.cam._cameraTable
self.ts = NTTopicSet("photonvision", self.cam.getName())
self.ts.updateEntries()
# Handle this last explicitly for this function signature because the other constructor is called in the initialiser list
if (
camera is not None
and props is not None
and minTargetAreaPercent is not None
and maxSightRange is not None
):
if minTargetAreaPercent is not None and maxSightRange is not None:
self.minTargetAreaPercent = minTargetAreaPercent
self.maxSightRange = maxSightRange
@@ -133,22 +122,39 @@ class PhotonCameraSim:
return self.videoSimFrameRaw
def canSeeTargetPose(self, camPose: Pose3d, target: VisionTargetSim) -> bool:
"""Determines if this target's pose should be visible to the camera without considering its
projected image points. Does not account for image area.
:param camPose: Camera's 3d pose
:param target: Vision target containing pose and shape
:returns: If this vision target can be seen before image projection.
"""
rel = CameraTargetRelation(camPose, target.getPose())
return (
(
# target translation is outside of camera's FOV
abs(rel.camToTargYaw.degrees())
< self.prop.getHorizFOV().degrees() / 2.0
and abs(rel.camToTargPitch.degrees())
< self.prop.getVertFOV().degrees() / 2.0
)
and (
# camera is behind planar target and it should be occluded
not target.getModel().getIsPlanar()
or abs(rel.targtoCamAngle.degrees()) < 90
)
# target is too far
and rel.camToTarg.translation().norm() <= self.maxSightRange
)
def canSeeCorner(self, points: np.ndarray) -> bool:
"""Determines if all target points are inside the camera's image.
:param points: The target's 2d image points
"""
assert points.shape[1] == 1
assert points.shape[2] == 2
for pt in points:
@@ -160,53 +166,90 @@ class PhotonCameraSim:
or y < 0
or y > self.prop.getResHeight()
):
return False
return False # point is outside of resolution
return True
def consumeNextEntryTime(self) -> float | None:
"""Determine if this camera should process a new frame based on performance metrics and the time
since the last update. This returns an Optional which is either empty if no update should occur
or a Long of the timestamp in microseconds of when the frame which should be received by NT. If
a timestamp is returned, the last frame update time becomes that timestamp.
:returns: Optional long which is empty while blocked or the NT entry timestamp in microseconds if
ready
"""
# check if this camera is ready for another frame update
now = int(wpilib.Timer.getFPGATimestamp() * 1e6)
timestamp = 0
iter = 0
# prepare next latest update
while now >= self.nextNtEntryTime:
timestamp = int(self.nextNtEntryTime)
frameTime = int(self.prop.estSecUntilNextFrame() * 1e6)
self.nextNtEntryTime += frameTime
# if frame time is very small, avoid blocking
iter += 1
if iter > 50:
timestamp = now
self.nextNtEntryTime = now + frameTime
break
# return the timestamp of the latest update
if timestamp != 0:
return timestamp
# or this camera isn't ready to process yet
return None
def setMinTargetAreaPercent(self, areaPercent: float) -> None:
"""The minimum percentage(0 - 100) a detected target must take up of the camera's image to be
processed.
"""
self.minTargetAreaPercent = areaPercent
def setMinTargetAreaPixels(self, areaPx: float) -> None:
"""The minimum number of pixels a detected target must take up in the camera's image to be
processed.
"""
self.minTargetAreaPercent = areaPx / self.prop.getResArea() * 100.0
def setMaxSightRange(self, range: meters) -> None:
"""Maximum distance at which the target is illuminated to your camera. Note that minimum target
area of the image is separate from this.
"""
self.maxSightRange = range
def enableRawStream(self, enabled: bool) -> None:
"""Sets whether the raw video stream simulation is enabled.
Note: This may increase loop times.
"""
self.videoSimRawEnabled = enabled
raise Exception("Raw stream not implemented")
# self.videoSimRawEnabled = enabled
def enableDrawWireframe(self, enabled: bool) -> None:
"""Sets whether a wireframe of the field is drawn to the raw video stream.
Note: This will dramatically increase loop times.
"""
self.videoSimWireframeEnabled = enabled
raise Exception("Wireframe not implemented")
# self.videoSimWireframeEnabled = enabled
def setWireframeResolution(self, resolution: float) -> None:
"""Sets the resolution of the drawn wireframe if enabled. Drawn line segments will be subdivided
into smaller segments based on a threshold set by the resolution.
:param resolution: Resolution as a fraction(0 - 1) of the video frame's diagonal length in
pixels
"""
self.videoSimWireframeResolution = resolution
def enableProcessedStream(self, enabled: bool) -> None:
"""Sets whether the processed video stream simulation is enabled."""
self.videoSimProcEnabled = enabled
raise Exception("Processed stream not implemented")
# self.videoSimProcEnabled = enabled
def process(
self, latency: seconds, cameraPose: Pose3d, targets: list[VisionTargetSim]
@@ -217,27 +260,32 @@ class PhotonCameraSim:
targets.sort(key=distance, reverse=True)
visibleTgts: list[
typing.Tuple[VisionTargetSim, list[typing.Tuple[float, float]]]
] = []
# all targets visible before noise
visibleTgts: list[typing.Tuple[VisionTargetSim, np.ndarray]] = []
# all targets actually detected by camera (after noise)
detectableTgts: list[PhotonTrackedTarget] = []
# basis change from world coordinates to camera coordinates
camRt = RotTrlTransform3d.makeRelativeTo(cameraPose)
for tgt in targets:
# pose isn't visible, skip to next
if not self.canSeeTargetPose(cameraPose, tgt):
continue
# find target's 3d corner points
fieldCorners = tgt.getFieldVertices()
isSpherical = tgt.getModel().getIsSpherical()
if isSpherical:
if isSpherical: # target is spherical
model = tgt.getModel()
# orient the model to the camera (like a sprite/decal) so it appears similar regardless of view
fieldCorners = model.getFieldVertices(
TargetModel.getOrientedPose(
tgt.getPose().translation(), cameraPose.translation()
)
)
# project 3d target points into 2d image points
imagePoints = OpenCVHelp.projectPoints(
self.prop.getIntrinsics(),
self.prop.getDistCoeffs(),
@@ -245,9 +293,11 @@ class PhotonCameraSim:
fieldCorners,
)
# spherical targets need a rotated rectangle of their midpoints for visualization
if isSpherical:
center = OpenCVHelp.avgPoint(imagePoints)
l: int = 0
# reference point (left side midpoint)
for i in range(4):
if imagePoints[i, 0, 0] < imagePoints[l, 0, 0].x:
l = i
@@ -258,6 +308,7 @@ class PhotonCameraSim:
] * 4
t = (l + 1) % 4
b = (l + 1) % 4
r = 0
for i in range(4):
if i == l:
continue
@@ -270,24 +321,32 @@ class PhotonCameraSim:
for i in range(4):
if i != t and i != l and i != b:
r = i
# create RotatedRect from midpoints
rect = cv.RotatedRect(
center,
(center[0, 0], center[0, 1]),
(
imagePoints[r, 0, 0] - lc[0, 0],
imagePoints[b, 0, 1] - imagePoints[t, 0, 1],
),
-angles[r],
)
imagePoints = rect.points()
# set target corners to rect corners
imagePoints = np.array([[p[0], p[1], p[2]] for p in rect.points()])
# save visible targets for raw video stream simulation
visibleTgts.append((tgt, imagePoints))
# estimate pixel noise
noisyTargetCorners = self.prop.estPixelNoise(imagePoints)
# find the minimum area rectangle of target corners
minAreaRect = OpenCVHelp.getMinAreaRect(noisyTargetCorners)
minAreaRectPts = minAreaRect.points()
# find the (naive) 2d yaw/pitch
centerPt = minAreaRect.center
centerRot = self.prop.getPixelRot(centerPt)
# find contour area
areaPercent = self.prop.getContourAreaPercent(noisyTargetCorners)
# projected target can't be detected, skip to next
if (
not self.canSeeCorner(noisyTargetCorners)
or not areaPercent >= self.minTargetAreaPercent
@@ -296,6 +355,7 @@ class PhotonCameraSim:
pnpSim: PnpResult | None = None
if tgt.fiducialId >= 0 and len(tgt.getFieldVertices()) == 4:
# single AprilTag solvePNP
pnpSim = OpenCVHelp.solvePNP_Square(
self.prop.getIntrinsics(),
self.prop.getDistCoeffs(),
@@ -325,13 +385,14 @@ class PhotonCameraSim:
)
# Video streams disabled for now
if self.enableRawStream:
if self.videoSimRawEnabled:
# TODO Video streams disabled for now port and uncomment when implemented
# VideoSimUtil::UpdateVideoProp(videoSimRaw, prop);
# cv::Size videoFrameSize{prop.GetResWidth(), prop.GetResHeight()};
# cv::Mat blankFrame = cv::Mat::zeros(videoFrameSize, CV_8UC1);
# blankFrame.assignTo(videoSimFrameRaw);
pass
if self.enableProcessedStream:
if self.videoSimProcEnabled:
# VideoSimUtil::UpdateVideoProp(videoSimProcessed, prop);
pass
@@ -343,6 +404,7 @@ class PhotonCameraSim:
if len(visibleLayoutTags) > 1:
usedIds = [tag.ID for tag in visibleLayoutTags]
# sort target order sorts in ascending order by default
usedIds.sort()
pnpResult = VisionEstimation.estimateCamPosePNP(
self.prop.getIntrinsics(),
@@ -354,10 +416,16 @@ class PhotonCameraSim:
if pnpResult is not None:
multiTagResults = MultiTargetPNPResult(pnpResult, usedIds)
# put this simulated data to NT
self.heartbeatCounter += 1
now_micros = wpilib.Timer.getFPGATimestamp() * 1e6
return PhotonPipelineResult(
metadata=PhotonPipelineMetadata(
self.heartbeatCounter, int(latency * 1e6), 1000000
self.heartbeatCounter,
int(now_micros - latency * 1e6),
int(now_micros),
# Pretend like we heard a pong recently
int(np.random.uniform(950, 1050)),
),
targets=detectableTgts,
multitagResult=multiTagResults,
@@ -366,6 +434,13 @@ class PhotonCameraSim:
def submitProcessedFrame(
self, result: PhotonPipelineResult, receiveTimestamp: float | None
):
"""Simulate one processed frame of vision data, putting one result to NT. Image capture timestamp
overrides :meth:`.PhotonPipelineResult.getTimestampSeconds` for more
precise latency simulation.
:param result: The pipeline result to submit
:param receiveTimestamp: The (sim) timestamp when this result was read by NT in microseconds. If not passed image capture time is assumed be (current time - latency)
"""
if receiveTimestamp is None:
receiveTimestamp = wpilib.Timer.getFPGATimestamp() * 1e6
receiveTimestamp = int(receiveTimestamp)
@@ -385,6 +460,7 @@ class PhotonCameraSim:
self.ts.targetSkewEntry.set(0.0, receiveTimestamp)
else:
bestTarget = result.getBestTarget()
assert bestTarget
self.ts.targetPitchEntry.set(bestTarget.getPitch(), receiveTimestamp)
self.ts.targetYawEntry.set(bestTarget.getYaw(), receiveTimestamp)

View File

@@ -11,7 +11,22 @@ from ..estimation import RotTrlTransform3d
class SimCameraProperties:
def __init__(self, path: str | None = None, width: int = 0, height: int = 0):
"""Calibration and performance values for this camera.
The resolution will affect the accuracy of projected(3d to 2d) target corners and similarly
the severity of image noise on estimation(2d to 3d).
The camera intrinsics and distortion coefficients describe the results of calibration, and how
to map between 3d field points and 2d image points.
The performance values (framerate/exposure time, latency) determine how often results should
be updated and with how much latency in simulation. High exposure time causes motion blur which
can inhibit target detection while moving. Note that latency estimation does not account for
network latency and the latency reported will always be perfect.
"""
def __init__(self):
"""Default constructor which is the same as {@link #PERFECT_90DEG}"""
self.resWidth: int = -1
self.resHeight: int = -1
self.camIntrinsics: np.ndarray = np.zeros((3, 3)) # [3,3]
@@ -24,63 +39,52 @@ class SimCameraProperties:
self.latencyStdDev: seconds = 0.0
self.viewplanes: list[np.ndarray] = [] # [3,1]
if path is None:
self.setCalibration(960, 720, fovDiag=Rotation2d(math.radians(90.0)))
else:
raise Exception("not yet implemented")
self.setCalibrationFromFOV(960, 720, fovDiag=Rotation2d(math.radians(90.0)))
def setCalibration(
def setCalibrationFromFOV(
self, width: int, height: int, fovDiag: Rotation2d
) -> None:
if fovDiag.degrees() < 1.0 or fovDiag.degrees() > 179.0:
fovDiag = Rotation2d.fromDegrees(max(min(fovDiag.degrees(), 179.0), 1.0))
logging.error("Requested invalid FOV! Clamping between (1, 179) degrees...")
resDiag = math.sqrt(width * width + height * height)
diagRatio = math.tan(fovDiag.radians() / 2.0)
fovWidth = Rotation2d(math.atan((diagRatio * (width / resDiag)) * 2))
fovHeight = Rotation2d(math.atan(diagRatio * (height / resDiag)) * 2)
# assume no distortion
newDistCoeffs = np.zeros((8, 1))
# assume centered principal point (pixels)
cx = width / 2.0 - 0.5
cy = height / 2.0 - 0.5
# use given fov to determine focal point (pixels)
fx = cx / math.tan(fovWidth.radians() / 2.0)
fy = cy / math.tan(fovHeight.radians() / 2.0)
# create camera intrinsics matrix
newCamIntrinsics = np.array([[fx, 0.0, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]])
self.setCalibrationFromIntrinsics(
width, height, newCamIntrinsics, newDistCoeffs
)
def setCalibrationFromIntrinsics(
self,
width: int,
height: int,
*,
fovDiag: Rotation2d | None = None,
newCamIntrinsics: np.ndarray | None = None,
newDistCoeffs: np.ndarray | None = None,
):
# Should be an inverted XOR on the args to differentiate between the signatures
has_fov_args = fovDiag is not None
has_matrix_args = newCamIntrinsics is not None and newDistCoeffs is not None
if (has_fov_args and has_matrix_args) or (
not has_matrix_args and not has_fov_args
):
raise Exception("not a correct function sig")
if has_fov_args:
if fovDiag.degrees() < 1.0 or fovDiag.degrees() > 179.0:
fovDiag = Rotation2d.fromDegrees(
max(min(fovDiag.degrees(), 179.0), 1.0)
)
logging.error(
"Requested invalid FOV! Clamping between (1, 179) degrees..."
)
resDiag = math.sqrt(width * width + height * height)
diagRatio = math.tan(fovDiag.radians() / 2.0)
fovWidth = Rotation2d(math.atan((diagRatio * (width / resDiag)) * 2))
fovHeight = Rotation2d(math.atan(diagRatio * (height / resDiag)) * 2)
newDistCoeffs = np.zeros((8, 1))
cx = width / 2.0 - 0.5
cy = height / 2.0 - 0.5
fx = cx / math.tan(fovWidth.radians() / 2.0)
fy = cy / math.tan(fovHeight.radians() / 2.0)
newCamIntrinsics = np.array([[fx, 0.0, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]])
# really convince python we are doing the right thing
assert newCamIntrinsics is not None
assert newDistCoeffs is not None
newCamIntrinsics: np.ndarray,
newDistCoeffs: np.ndarray,
) -> None:
self.resWidth = width
self.resHeight = height
self.camIntrinsics = newCamIntrinsics
self.distCoeffs = newDistCoeffs
# left, right, up, and down view planes
p = [
Translation3d(
1.0,
@@ -126,16 +130,33 @@ class SimCameraProperties:
self.errorStdDevPx = newErrorStdDevPx
def setFPS(self, fps: hertz):
"""
:param fps: The average frames per second the camera should process at. :strong:`Exposure time limits
FPS if set!`
"""
self.frameSpeed = max(1.0 / fps, self.exposureTime)
def setExposureTime(self, newExposureTime: seconds):
"""
:param newExposureTime: The amount of time the "shutter" is open for one frame. Affects motion
blur. **Frame speed(from FPS) is limited to this!**
"""
self.exposureTime = newExposureTime
self.frameSpeed = max(self.frameSpeed, self.exposureTime)
def setAvgLatency(self, newAvgLatency: seconds):
"""
:param newAvgLatency: The average latency (from image capture to data published) in milliseconds
a frame should have
"""
self.vgLatency = newAvgLatency
def setLatencyStdDev(self, newLatencyStdDev: seconds):
"""
:param latencyStdDevMs: The standard deviation in milliseconds of the latency
"""
self.latencyStdDev = newLatencyStdDev
def getResWidth(self) -> int:
@@ -171,31 +192,72 @@ class SimCameraProperties:
def getLatencyStdDev(self) -> seconds:
return self.latencyStdDev
def getContourAreaPercent(self, points: list[typing.Tuple[float, float]]) -> float:
return (
cv.contourArea(cv.convexHull(np.array(points))) / self.getResArea() * 100.0
)
def getContourAreaPercent(self, points: np.ndarray) -> float:
"""The percentage(0 - 100) of this camera's resolution the contour takes up in pixels of the
image.
:param points: Points of the contour
"""
return cv.contourArea(cv.convexHull(points)) / self.getResArea() * 100.0
def getPixelYaw(self, pixelX: float) -> Rotation2d:
"""The yaw from the principal point of this camera to the pixel x value. Positive values left."""
fx = self.camIntrinsics[0, 0]
# account for principal point not being centered
cx = self.camIntrinsics[0, 2]
xOffset = cx - pixelX
return Rotation2d(fx, xOffset)
def getPixelPitch(self, pixelY: float) -> Rotation2d:
"""The pitch from the principal point of this camera to the pixel y value. Pitch is positive down.
Note that this angle is naively computed and may be incorrect. See {@link
#getCorrectedPixelRot(Point)}.
"""
fy = self.camIntrinsics[1, 1]
# account for principal point not being centered
cy = self.camIntrinsics[1, 2]
yOffset = cy - pixelY
return Rotation2d(fy, -yOffset)
def getPixelRot(self, point: typing.Tuple[int, int]) -> Rotation3d:
def getPixelRot(self, point: cv.typing.Point2f) -> Rotation3d:
"""Finds the yaw and pitch to the given image point. Yaw is positive left, and pitch is positive
down.
Note that pitch is naively computed and may be incorrect. See {@link
#getCorrectedPixelRot(Point)}.
"""
return Rotation3d(
0.0,
self.getPixelPitch(point[1]).radians(),
self.getPixelYaw(point[0]).radians(),
)
def getCorrectedPixelRot(self, point: typing.Tuple[float, float]) -> Rotation3d:
def getCorrectedPixelRot(self, point: cv.typing.Point2f) -> Rotation3d:
"""Gives the yaw and pitch of the line intersecting the camera lens and the given pixel
coordinates on the sensor. Yaw is positive left, and pitch positive down.
The pitch traditionally calculated from pixel offsets do not correctly account for non-zero
values of yaw because of perspective distortion (not to be confused with lens distortion)-- for
example, the pitch angle is naively calculated as:
<pre>pitch = arctan(pixel y offset / focal length y)</pre>
However, using focal length as a side of the associated right triangle is not correct when the
pixel x value is not 0, because the distance from this pixel (projected on the x-axis) to the
camera lens increases. Projecting a line back out of the camera with these naive angles will
not intersect the 3d point that was originally projected into this 2d pixel. Instead, this
length should be:
<pre>focal length y ⟶ (focal length y / cos(arctan(pixel x offset / focal length x)))</pre>
:returns: Rotation3d with yaw and pitch of the line projected out of the camera from the given
pixel (roll is zero).
"""
fx = self.camIntrinsics[0, 0]
cx = self.camIntrinsics[0, 2]
xOffset = cx - point[0]
@@ -209,11 +271,13 @@ class SimCameraProperties:
return Rotation3d(0.0, pitch.radians(), yaw.radians())
def getHorizFOV(self) -> Rotation2d:
# sum of FOV left and right principal point
left = self.getPixelYaw(0)
right = self.getPixelYaw(self.resWidth)
return left - right
def getVertFOV(self) -> Rotation2d:
# sum of FOV above and below principal point
above = self.getPixelPitch(0)
below = self.getPixelPitch(self.resHeight)
return below - above
@@ -226,9 +290,34 @@ class SimCameraProperties:
def getVisibleLine(
self, camRt: RotTrlTransform3d, a: Translation3d, b: Translation3d
) -> typing.Tuple[float | None, float | None]:
relA = camRt.apply(a)
relB = camRt.apply(b)
"""Determines where the line segment defined by the two given translations intersects the camera's
frustum/field-of-vision, if at all.
The line is parametrized so any of its points <code>p = t * (b - a) + a</code>. This method
returns these values of t, minimum first, defining the region of the line segment which is
visible in the frustum. If both ends of the line segment are visible, this simply returns {0,
1}. If, for example, point b is visible while a is not, and half of the line segment is inside
the camera frustum, {0.5, 1} would be returned.
:param camRt: The change in basis from world coordinates to camera coordinates. See {@link
RotTrlTransform3d#makeRelativeTo(Pose3d)}.
:param a: The initial translation of the line
:param b: The final translation of the line
:returns: A Pair of Doubles. The values may be null:
- {Double, Double} : Two parametrized values(t), minimum first, representing which
segment of the line is visible in the camera frustum.
- {Double, null} : One value(t) representing a single intersection point. For example,
the line only intersects the intersection of two adjacent viewplanes.
- {null, null} : No values. The line segment is not visible in the camera frustum.
"""
# translations relative to the camera
relA = camRt.applyTranslation(a)
relB = camRt.applyTranslation(b)
# check if both ends are behind camera
if relA.X() <= 0.0 and relB.X() <= 0.0:
return (None, None)
@@ -239,6 +328,7 @@ class SimCameraProperties:
aVisible = True
bVisible = True
# check if the ends of the line segment are visible
for normal in self.viewplanes:
aVisibility = av.dot(normal)
if aVisibility < 0:
@@ -247,39 +337,55 @@ class SimCameraProperties:
bVisibility = bv.dot(normal)
if bVisibility < 0:
bVisible = False
# both ends are outside at least one of the same viewplane
if aVisibility <= 0 and bVisibility <= 0:
return (None, None)
# both ends are inside frustum
if aVisible and bVisible:
return (0.0, 1.0)
# parametrized (t=0 at a, t=1 at b) intersections with viewplanes
intersections = [float("nan"), float("nan"), float("nan"), float("nan")]
# Optionally 3x1 vector
ipts: typing.List[np.ndarray | None] = [None, None, None, None]
# find intersections
for i, normal in enumerate(self.viewplanes):
# // we want to know the value of t when the line intercepts this plane
# // parametrized: v = t * ab + a, where v lies on the plane
# // we can find the projection of a onto the plane normal
# // a_projn = normal.times(av.dot(normal) / normal.dot(normal));
a_projn = (av.dot(normal) / normal.dot(normal)) * normal
# // this projection lets us determine the scalar multiple t of ab where
# // (t * ab + a) is a vector which lies on the plane
if abs(abv.dot(normal)) < 1.0e-5:
continue
intersections[i] = a_projn.dot(a_projn) / -(abv.dot(a_projn))
# // vector a to the viewplane
apv = intersections[i] * abv
# av + apv = intersection point
intersectpt = av + apv
ipts[i] = intersectpt
# // discard intersections outside the camera frustum
for j in range(1, len(self.viewplanes)):
if j == 0:
continue
oi = (i + j) % len(self.viewplanes)
onormal = self.viewplanes[oi]
# if the dot of the intersection point with any plane normal is negative, it is outside
if intersectpt.dot(onormal) < 0:
intersections[i] = float("nan")
ipts[i] = None
break
if not ipts[i]:
# // discard duplicate intersections
if ipts[i] is None:
continue
for j in range(i - 1, 0 - 1):
@@ -293,6 +399,7 @@ class SimCameraProperties:
ipts[i] = None
break
# determine visible segment (minimum and maximum t)
inter1 = float("nan")
inter2 = float("nan")
for inter in intersections:
@@ -302,6 +409,7 @@ class SimCameraProperties:
else:
inter2 = inter
# // two viewplane intersections
if not math.isnan(inter2):
max_ = max(inter1, inter2)
min_ = min(inter1, inter2)
@@ -310,16 +418,19 @@ class SimCameraProperties:
if bVisible:
max_ = 1
return (min_, max_)
# // one viewplane intersection
elif not math.isnan(inter1):
if aVisible:
return (0, inter1)
if bVisible:
return (inter1, 1)
return (inter1, None)
# no intersections
else:
return (None, None)
def estPixelNoise(self, points: np.ndarray) -> np.ndarray:
"""Returns these points after applying this camera's estimated noise."""
assert points.shape[1] == 1, points.shape
assert points.shape[2] == 2, points.shape
if self.avgErrorPx == 0 and self.errorStdDevPx == 0:
@@ -327,6 +438,7 @@ class SimCameraProperties:
noisyPts: list[list] = []
for p in points:
# // error pixels in random direction
error = np.random.normal(self.avgErrorPx, self.errorStdDevPx, 1)[0]
errorAngle = np.random.uniform(-math.pi, math.pi)
noisyPts.append(
@@ -342,22 +454,31 @@ class SimCameraProperties:
return retval
def estLatency(self) -> seconds:
"""
:returns: Noisy estimation of a frame's processing latency
"""
return max(
float(np.random.normal(self.avgLatency, self.latencyStdDev, 1)[0]),
0.0,
)
def estSecUntilNextFrame(self) -> seconds:
"""
:returns: Estimate how long until the next frame should be processed in milliseconds
"""
# // exceptional processing latency blocks the next frame
return self.frameSpeed + max(0.0, self.estLatency() - self.frameSpeed)
@classmethod
def PERFECT_90DEG(cls) -> typing.Self:
"""960x720 resolution, 90 degree FOV, "perfect" lagless camera"""
return cls()
@classmethod
def PI4_LIFECAM_320_240(cls) -> typing.Self:
prop = cls()
prop.setCalibration(
prop.setCalibrationFromIntrinsics(
320,
240,
newCamIntrinsics=np.array(
@@ -391,7 +512,7 @@ class SimCameraProperties:
@classmethod
def PI4_LIFECAM_640_480(cls) -> typing.Self:
prop = cls()
prop.setCalibration(
prop.setCalibrationFromIntrinsics(
640,
480,
newCamIntrinsics=np.array(
@@ -425,7 +546,7 @@ class SimCameraProperties:
@classmethod
def LL2_640_480(cls) -> typing.Self:
prop = cls()
prop.setCalibration(
prop.setCalibrationFromIntrinsics(
640,
480,
newCamIntrinsics=np.array(
@@ -459,7 +580,7 @@ class SimCameraProperties:
@classmethod
def LL2_960_720(cls) -> typing.Self:
prop = cls()
prop.setCalibration(
prop.setCalibrationFromIntrinsics(
960,
720,
newCamIntrinsics=np.array(
@@ -493,7 +614,7 @@ class SimCameraProperties:
@classmethod
def LL2_1280_720(cls) -> typing.Self:
prop = cls()
prop.setCalibration(
prop.setCalibrationFromIntrinsics(
1280,
720,
newCamIntrinsics=np.array(
@@ -527,7 +648,7 @@ class SimCameraProperties:
@classmethod
def OV9281_640_480(cls) -> typing.Self:
prop = cls()
prop.setCalibration(
prop.setCalibrationFromIntrinsics(
640,
480,
newCamIntrinsics=np.array(
@@ -561,7 +682,7 @@ class SimCameraProperties:
@classmethod
def OV9281_800_600(cls) -> typing.Self:
prop = cls()
prop.setCalibration(
prop.setCalibrationFromIntrinsics(
800,
600,
newCamIntrinsics=np.array(
@@ -595,7 +716,7 @@ class SimCameraProperties:
@classmethod
def OV9281_1280_720(cls) -> typing.Self:
prop = cls()
prop.setCalibration(
prop.setCalibrationFromIntrinsics(
1280,
720,
newCamIntrinsics=np.array(
@@ -629,7 +750,7 @@ class SimCameraProperties:
@classmethod
def OV9281_1920_1080(cls) -> typing.Self:
prop = cls()
prop.setCalibration(
prop.setCalibrationFromIntrinsics(
1920,
1080,
newCamIntrinsics=np.array(

View File

@@ -15,7 +15,22 @@ from .visionTargetSim import VisionTargetSim
class VisionSystemSim:
"""A simulated vision system involving a camera(s) and coprocessor(s) mounted on a mobile robot
running PhotonVision, detecting targets placed on the field. :class:`.VisionTargetSim`s added to
this class will be detected by the :class:`.PhotonCameraSim`s added to this class. This class
should be updated periodically with the robot's current pose in order to publish the simulated
camera target info.
"""
def __init__(self, visionSystemName: str):
"""A simulated vision system involving a camera(s) and coprocessor(s) mounted on a mobile robot
running PhotonVision, detecting targets placed on the field. :class:`.VisionTargetSim`s added to
this class will be detected by the :class:`.PhotonCameraSim`s added to this class. This class
should be updated periodically with the robot's current pose in order to publish the simulated
camera target info.
:param visionSystemName: The specific identifier for this vision system in NetworkTables.
"""
self.dbgField: Field2d = Field2d()
self.bufferLength: seconds = 1.5
@@ -32,12 +47,21 @@ class VisionSystemSim:
wpilib.SmartDashboard.putData(self.tableName + "/Sim Field", self.dbgField)
def getCameraSim(self, name: str) -> PhotonCameraSim | None:
"""Get one of the simulated cameras."""
return self.camSimMap.get(name, None)
def getCameraSims(self) -> list[PhotonCameraSim]:
"""Get all the simulated cameras."""
return [*self.camSimMap.values()]
def addCamera(self, cameraSim: PhotonCameraSim, robotToCamera: Transform3d) -> None:
"""Adds a simulated camera to this vision system with a specified robot-to-camera transformation.
The vision targets registered with this vision system simulation will be observed by the
simulated :class:`.PhotonCamera`.
:param cameraSim: The camera simulation
:param robotToCamera: The transform from the robot pose to the camera pose
"""
name = cameraSim.getCamera().getName()
if name not in self.camSimMap:
self.camSimMap[name] = cameraSim
@@ -49,10 +73,15 @@ class VisionSystemSim:
)
def clearCameras(self) -> None:
"""Remove all simulated cameras from this vision system."""
self.camSimMap.clear()
self.camTrfMap.clear()
def removeCamera(self, cameraSim: PhotonCameraSim) -> bool:
"""Remove a simulated camera from this vision system.
:returns: If the camera was present and removed
"""
name = cameraSim.getCamera().getName()
if name in self.camSimMap:
del self.camSimMap[name]
@@ -65,6 +94,14 @@ class VisionSystemSim:
cameraSim: PhotonCameraSim,
time: seconds = wpilib.Timer.getFPGATimestamp(),
) -> Transform3d | None:
"""Get a simulated camera's position relative to the robot. If the requested camera is invalid, an
empty optional is returned.
:param cameraSim: The specific camera to get the robot-to-camera transform of
:param timeSeconds: Timestamp in seconds of when the transform should be observed
:returns: The transform of this camera, or an empty optional if it is invalid
"""
if cameraSim in self.camTrfMap:
trfBuffer = self.camTrfMap[cameraSim]
sample = trfBuffer.sample(time)
@@ -80,15 +117,34 @@ class VisionSystemSim:
cameraSim: PhotonCameraSim,
time: seconds = wpilib.Timer.getFPGATimestamp(),
) -> Pose3d | None:
"""Get a simulated camera's position on the field. If the requested camera is invalid, an empty
optional is returned.
:param cameraSim: The specific camera to get the field pose of
:returns: The pose of this camera, or an empty optional if it is invalid
"""
robotToCamera = self.getRobotToCamera(cameraSim, time)
if robotToCamera is None:
return None
else:
return self.getRobotPose(time) + robotToCamera
pose = self.getRobotPose(time)
if pose:
return pose + robotToCamera
else:
return None
def adjustCamera(
self, cameraSim: PhotonCameraSim, robotToCamera: Transform3d
) -> bool:
"""Adjust a camera's position relative to the robot. Use this if your camera is on a gimbal or
turret or some other mobile platform.
:param cameraSim: The simulated camera to change the relative position of
:param robotToCamera: New transform from the robot to the camera
:returns: If the cameraSim was valid and transform was adjusted
"""
if cameraSim in self.camTrfMap:
self.camTrfMap[cameraSim].addSample(
wpilib.Timer.getFPGATimestamp(), Pose3d() + robotToCamera
@@ -98,6 +154,7 @@ class VisionSystemSim:
return False
def resetCameraTransforms(self, cameraSim: PhotonCameraSim | None = None) -> None:
"""Reset the transform history for this camera to just the current transform."""
now = wpilib.Timer.getFPGATimestamp()
def resetSingleCamera(self, cameraSim: PhotonCameraSim) -> bool:
@@ -129,12 +186,30 @@ class VisionSystemSim:
def addVisionTargets(
self, targets: list[VisionTargetSim], targetType: str = "targets"
) -> None:
"""Adds targets on the field which your vision system is designed to detect. The {@link
PhotonCamera}s simulated from this system will report the location of the camera relative to
the subset of these targets which are visible from the given camera position.
:param targets: Targets to add to the simulated field
:param type: Type of target (e.g. "cargo").
"""
if targetType not in self.targetSets:
self.targetSets[targetType] = targets
else:
self.targetSets[targetType] += targets
def addAprilTags(self, layout: AprilTagFieldLayout) -> None:
"""Adds targets on the field which your vision system is designed to detect. The {@link
PhotonCamera}s simulated from this system will report the location of the camera relative to
the subset of these targets which are visible from the given camera position.
The AprilTags from this layout will be added as vision targets under the type "apriltag".
The poses added preserve the tag layout's current alliance origin. If the tag layout's alliance
origin is changed, these added tags will have to be cleared and re-added.
:param tagLayout: The field tag layout to get Apriltag poses and IDs from
"""
targets: list[VisionTargetSim] = []
for tag in layout.getTags():
tag_pose = layout.getTagPose(tag.ID)
@@ -167,10 +242,16 @@ class VisionSystemSim:
def getRobotPose(
self, timestamp: seconds = wpilib.Timer.getFPGATimestamp()
) -> Pose3d:
) -> Pose3d | None:
"""Get the robot pose in meters saved by the vision system at this timestamp.
:param timestamp: Timestamp of the desired robot pose
"""
return self.robotPoseBuffer.sample(timestamp)
def resetRobotPose(self, robotPose: Pose2d | Pose3d) -> None:
"""Clears all previous robot poses and sets robotPose at current time."""
if type(robotPose) is Pose2d:
robotPose = Pose3d(robotPose)
assert type(robotPose) is Pose3d
@@ -182,16 +263,23 @@ class VisionSystemSim:
return self.dbgField
def update(self, robotPose: Pose2d | Pose3d) -> None:
"""Periodic update. Ensure this is called repeatedly-- camera performance is used to automatically
determine if a new frame should be submitted.
:param robotPoseMeters: The simulated robot pose in meters
"""
if type(robotPose) is Pose2d:
robotPose = Pose3d(robotPose)
assert type(robotPose) is Pose3d
# update vision targets on field
for targetType, targets in self.targetSets.items():
posesToAdd: list[Pose2d] = []
for target in targets:
posesToAdd.append(target.getPose().toPose2d())
self.dbgField.getObject(targetType).setPoses(posesToAdd)
# save "real" robot poses over time
now = wpilib.Timer.getFPGATimestamp()
self.robotPoseBuffer.addSample(now, robotPose)
self.dbgField.setRobotPose(robotPose.toPose2d())
@@ -204,27 +292,36 @@ class VisionSystemSim:
visTgtPoses2d: list[Pose2d] = []
cameraPoses2d: list[Pose2d] = []
processed = False
# process each camera
for camSim in self.camSimMap.values():
# check if this camera is ready to process and get latency
optTimestamp = camSim.consumeNextEntryTime()
if optTimestamp is None:
continue
else:
processed = True
# when this result "was" read by NT
timestampNt = optTimestamp
latency = camSim.prop.estLatency()
# the image capture timestamp in seconds of this result
timestampCapture = timestampNt * 1.0e-6 - latency
# use camera pose from the image capture timestamp
lateRobotPose = self.getRobotPose(timestampCapture)
lateCameraPose = lateRobotPose + self.getRobotToCamera(
camSim, timestampCapture
)
robotToCamera = self.getRobotToCamera(camSim, timestampCapture)
if lateRobotPose is None or robotToCamera is None:
return None
lateCameraPose = lateRobotPose + robotToCamera
cameraPoses2d.append(lateCameraPose.toPose2d())
# process a PhotonPipelineResult with visible targets
camResult = camSim.process(latency, lateCameraPose, allTargets)
# publish this info to NT at estimated timestamp of receive
camSim.submitProcessedFrame(camResult, timestampNt)
for target in camResult.getTargets():
trf = target.getBestCameraToTarget()
# display debug results
for tgt in camResult.getTargets():
trf = tgt.getBestCameraToTarget()
if trf == Transform3d():
continue

View File

@@ -6,7 +6,16 @@ from ..estimation.targetModel import TargetModel
class VisionTargetSim:
"""Describes a vision target located somewhere on the field that your vision system can detect."""
def __init__(self, pose: Pose3d, model: TargetModel, id: int = -1):
"""Describes a fiducial tag located somewhere on the field that your vision system can detect.
:param pose: Pose3d of the tag in field-relative coordinates
:param model: TargetModel which describes the shape of the target(tag)
:param id: The ID of this fiducial tag
"""
self.pose: Pose3d = pose
self.model: TargetModel = model
self.fiducialId: int = id
@@ -47,4 +56,5 @@ class VisionTargetSim:
return self.model
def getFieldVertices(self) -> list[Translation3d]:
"""This target's vertices offset from its field pose."""
return self.model.getFieldVertices(self.pose)

View File

@@ -2,7 +2,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, ClassVar
if TYPE_CHECKING:
from .. import generated
from ..generated.TargetCornerSerde import TargetCornerSerde
@dataclass
@@ -10,4 +10,4 @@ class TargetCorner:
x: float = 0
y: float = 9
photonStruct: ClassVar["generated.TargetCornerSerde"]
photonStruct: ClassVar["TargetCornerSerde"]

View File

@@ -3,10 +3,9 @@ from typing import TYPE_CHECKING, ClassVar
from wpimath.geometry import Transform3d
from ..packet import Packet
if TYPE_CHECKING:
from .. import generated
from ..generated.MultiTargetPNPResultSerde import MultiTargetPNPResultSerde
from ..generated.PnpResultSerde import PnpResultSerde
@dataclass
@@ -17,7 +16,7 @@ class PnpResult:
bestReprojErr: float = 0.0
altReprojErr: float = 0.0
photonStruct: ClassVar["generated.PnpResultSerde"]
photonStruct: ClassVar["PnpResultSerde"]
@dataclass
@@ -27,14 +26,4 @@ class MultiTargetPNPResult:
estimatedPose: PnpResult = field(default_factory=PnpResult)
fiducialIDsUsed: list[int] = field(default_factory=list)
def createFromPacket(self, packet: Packet) -> Packet:
self.estimatedPose = PnpResult()
self.estimatedPose.createFromPacket(packet)
self.fiducialIDsUsed = []
for _ in range(MultiTargetPNPResult._MAX_IDS):
fidId = packet.decode16()
if fidId >= 0:
self.fiducialIDsUsed.append(fidId)
return packet
photonStruct: ClassVar["generated.MultiTargetPNPResultSerde"]
photonStruct: ClassVar["MultiTargetPNPResultSerde"]

View File

@@ -5,7 +5,8 @@ from .multiTargetPNPResult import MultiTargetPNPResult
from .photonTrackedTarget import PhotonTrackedTarget
if TYPE_CHECKING:
from .. import generated
from ..generated.PhotonPipelineMetadataSerde import PhotonPipelineMetadataSerde
from ..generated.PhotonPipelineResultSerde import PhotonPipelineResultSerde
@dataclass
@@ -20,7 +21,7 @@ class PhotonPipelineMetadata:
timeSinceLastPong: int = -1
photonStruct: ClassVar["generated.PhotonPipelineMetadataSerde"]
photonStruct: ClassVar["PhotonPipelineMetadataSerde"]
@dataclass
@@ -69,4 +70,4 @@ class PhotonPipelineResult:
return None
return self.getTargets()[0]
photonStruct: ClassVar["generated.PhotonPipelineResultSerde"]
photonStruct: ClassVar["PhotonPipelineResultSerde"]

View File

@@ -7,7 +7,7 @@ from ..packet import Packet
from .TargetCorner import TargetCorner
if TYPE_CHECKING:
from .. import generated
from ..generated.PhotonTrackedTargetSerde import PhotonTrackedTargetSerde
@dataclass
@@ -63,4 +63,4 @@ class PhotonTrackedTarget:
retList.append(TargetCorner(cx, cy))
return retList
photonStruct: ClassVar["generated.PhotonTrackedTargetSerde"]
photonStruct: ClassVar["PhotonTrackedTargetSerde"]

View File

@@ -0,0 +1,2 @@
[tool.mypy]
exclude = ["build","setup.py"]

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