[PhotonClient] Vite and Typescript complete refactor (#884)

This commit is contained in:
Sriman Achanta
2023-08-21 01:51:35 -04:00
committed by GitHub
parent 8397b43bef
commit f623e4a1cc
119 changed files with 11821 additions and 19318 deletions

View File

@@ -14,9 +14,7 @@ on:
merge_group:
jobs:
# This job builds the client (web view).
photonclient-build:
# Let all steps run within the photon-client dir.
defaults:
run:
@@ -33,19 +31,44 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 17
# Run npm
- run: npm update -g npm
- run: npm ci
- run: npm run build --if-present
- name: Install Dependencies
run: npm ci
- name: Build Production Client
run: npm run build
# Upload client artifact.
- uses: actions/upload-artifact@master
with:
name: built-client
path: photon-client/dist/
photonclient-check-lint:
# Let all steps run within the photon-client dir.
defaults:
run:
working-directory: photon-client
# The type of runner that the job will run on.
runs-on: ubuntu-22.04
steps:
# Checkout code.
- uses: actions/checkout@v3
# Setup Node.js
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 17
- name: Install Dependencies
run: npm ci
- name: Check Formatting
run: npm run lint
photon-build-examples:
runs-on: ubuntu-22.04
name: "Build Examples"
@@ -88,7 +111,6 @@ jobs:
chmod +x gradlew
./gradlew copyPhotonlib -x check
./gradlew buildAllExamples -x check --max-workers 2
photon-build-all:
# The type of runner that the job will run on.
runs-on: ubuntu-22.04
@@ -135,7 +157,6 @@ jobs:
uses: codecov/codecov-action@v3
with:
file: ./photon-core/build/reports/jacoco/test/jacocoTestReport.xml
photonserver-build-offline-docs:
runs-on: ubuntu-22.04
@@ -172,7 +193,6 @@ jobs:
with:
name: built-docs
path: build/html
photonserver-check-lint:
# The type of runner that the job will run on.
runs-on: ubuntu-22.04
@@ -193,8 +213,6 @@ jobs:
- run: |
chmod +x gradlew
./gradlew spotlessCheck
# Building photonlib
photonlib-build-host:
env:
MACOSX_DEPLOYMENT_TARGET: 10.14
@@ -229,7 +247,6 @@ jobs:
env:
ARTIFACTORY_API_KEY: ${{ secrets.ARTIFACTORY_API_KEY }}
if: github.event_name == 'push'
photonlib-build-docker:
strategy:
fail-fast: false
@@ -263,7 +280,6 @@ jobs:
env:
ARTIFACTORY_API_KEY: ${{ secrets.ARTIFACTORY_API_KEY }}
if: github.event_name == 'push'
photonlib-wpiformat:
name: "wpiformat"
runs-on: ubuntu-22.04
@@ -292,7 +308,6 @@ jobs:
name: wpiformat fixes
path: wpiformat-fixes.patch
if: ${{ failure() }}
photon-build-package:
needs: [photonclient-build, photon-build-all, photonserver-build-offline-docs]
@@ -378,8 +393,6 @@ jobs:
with:
name: jar-${{ matrix.artifact-name }}
path: photon-server/build/libs
photon-image-generator:
needs: [photon-build-package]
if: ${{ github.event_name != 'pull_request' }}
@@ -420,8 +433,6 @@ jobs:
with:
name: image-${{ matrix.image_suffix }}
path: photonvision*.xz
photon-release:
needs: [photon-build-package, photon-image-generator]
runs-on: ubuntu-22.04

View File

@@ -17,6 +17,7 @@ modifiableFileExclude {
\.so$
\.dll$
\.webp$
\.ico$
gradlew
}

View File

@@ -1,2 +0,0 @@
> 1%
last 2 versions

View File

@@ -1,17 +0,0 @@
module.exports = {
root: true,
env: {
node: true
},
'extends': [
'plugin:vue/recommended',
'eslint:recommended'
],
rules: {
'no-console': 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
},
parserOptions: {
parser: 'babel-eslint'
}
};

View File

@@ -0,0 +1,19 @@
{
"root": true,
"extends": [
"eslint:recommended",
"plugin:vue/recommended",
"@vue/eslint-config-typescript/recommended"
],
"rules": {
"quotes": ["error", "double"],
"comma-dangle": ["error", "never"],
"comma-spacing": ["error", { "before": false, "after": true }],
"semi": ["error", "always"],
"eol-last": "error",
"object-curly-spacing": ["error", "always"],
"quote-props": ["error", "as-needed"],
"no-case-declarations": "off",
"vue/require-default-prop": "off"
}
}

View File

@@ -1,21 +1,28 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.idea
.vscode
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
components.d.ts

View File

@@ -1,37 +0,0 @@
# PhotonVision Client UI
## Install Node.js
Follow [this](https://nodejs.org/en/) link.
## Project setup
Run this one time, this command downloads the packages the UI uses, and it might take a short while
```
npm install
```
### Compiles and hot-reloads for development
Run this every development session, this command auto-builds the UI after every change you make
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Run your tests
```
npm run test
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See Node.js' [Configuration Reference](https://cli.vuejs.org/config/).

View File

@@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/app'
]
};

1
photon-client/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

13
photon-client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Photon Client</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,41 @@
{
"name": "photon-client",
"version": "3.0.0",
"name": "photonclient",
"version": "0.0.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
"dev": "vite",
"build": "run-p build-only",
"preview": "vite preview --port 4173",
"build-only": "vite build",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"@fontsource/prompt": "^4.5.10",
"@fontsource/prompt": "^5.0.5",
"@mdi/font": "^7.2.96",
"@msgpack/msgpack": "^3.0.0-beta2",
"axios": "^1.4.0",
"core-js": "^3.30.2",
"jspdf": "^2.5.1",
"msgpack5": "^6.0.2",
"three": "^0.153.0",
"pinia": "^2.1.4",
"three": "^0.154.0",
"vue": "^2.7.14",
"vue-axios": "^3.5.2",
"vue-router": "^3.6.5",
"vuetify": "^2.6.15",
"vuex": "^3.6.2"
"vuetify": "^2.6.15"
},
"devDependencies": {
"@mdi/font": "^7.2.96",
"@vue/cli-plugin-eslint": "^4.5.19",
"@vue/cli-service": "^4.5.19",
"babel-eslint": "^10.1.0",
"eslint": "^5.16.0",
"eslint-plugin-vue": "^5.0.0",
"papaparse": "^5.4.1",
"sass": "^1.62.1",
"sass-loader": "^10.4.1",
"vue-cli-plugin-vuetify": "^0.6.3",
"vue-template-compiler": "^2.7.14",
"vuetify-loader": "^1.9.2"
"@types/node": "^16.11.45",
"@types/three": "^0.154.0",
"@vitejs/plugin-vue2": "^2.2.0",
"@vue/eslint-config-typescript": "^11.0.3",
"@vue/tsconfig": "^0.1.3",
"deepmerge": "^4.3.1",
"eslint": "^8.45.0",
"eslint-plugin-vue": "^9.0.0",
"npm-run-all": "^4.1.5",
"sass": "~1.32",
"sass-loader": "^13.3.2",
"terser": "^5.14.2",
"typescript": "~4.7.4",
"unplugin-vue-components": "^0.25.1",
"vite": "^4.3.9"
}
}

View File

@@ -1,5 +0,0 @@
module.exports = {
plugins: {
autoprefixer: {}
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 508 507" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-1279,0)">
<g id="PhotonVision-Icon-BG" transform="matrix(0.264062,0,0,0.469444,1279.5,0)">
<rect x="0" y="0" width="1920" height="1080" style="fill:none;"/>
<clipPath id="_clip1">
<rect x="0" y="0" width="1920" height="1080"/>
</clipPath>
<g clip-path="url(#_clip1)">
<g transform="matrix(4.27015,0,0,2.40196,-20444.8,-3235.56)">
<circle cx="5012.55" cy="1571.77" r="224.918" style="fill:rgb(0,100,146);"/>
</g>
<g transform="matrix(4.95901,0,0,2.78944,-13955,-10313.5)">
<path d="M3055.09,3977.51C3050.3,3984.25 3045,3990.56 3039.21,3996.35C2987.91,4047.65 2917.1,4038.77 2881.16,3976.54C2845.23,3914.3 2857.71,3822.13 2909.01,3770.83C2960.31,3719.53 3031.13,3728.41 3067.06,3790.64C3069.85,3795.48 3072.35,3800.49 3074.56,3805.67L3039.78,3811.64C3012.82,3769.64 2962.9,3764.58 2926.45,3801.04C2888.89,3838.59 2879.76,3906.07 2906.07,3951.63C2932.37,3997.19 2984.22,4003.69 3021.77,3966.14L3021.89,3966.01L3055.09,3977.51ZM3085.02,3841.47C3090.86,3875.56 3086.6,3912.35 3073.22,3944.57L3043.91,3934.42C3056.74,3907.59 3060.53,3875.54 3054.13,3846.78L3085.02,3841.47Z" style="fill:white;"/>
</g>
<g transform="matrix(4.95901,0,0,2.78944,-13955,-3827.86)">
<path d="M2906.78,1571.77L3111.02,1642.48L3116.61,1626.34L3147.2,1664.74L3099.42,1675.99L3105,1659.86L2910.03,1592.35C2908.25,1585.69 2907.18,1578.77 2906.78,1571.77ZM2917.45,1517.07L3114.77,1483.17L3111.88,1466.34L3157.2,1485.21L3120.78,1518.13L3117.88,1501.3L2910.22,1536.97C2911.99,1530.09 2914.41,1523.4 2917.45,1517.07Z" style="fill:rgb(255,216,67);"/>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<!--suppress HtmlUnknownTarget -->
<link rel="icon" href="<%= BASE_URL %>favicon.svg" type="image/png">
<title>PhotonVision Client</title>
</head>
<body>
<noscript>
<strong>We're sorry but PhotonVision doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View File

@@ -1,306 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>ThinClient</title>
<style>
* {
margin: 0;
padding: 0;
}
.img-container {
height: 100%;
width: 40vw;
}
</style>
</head>
<body>
<hr>
<div class="img-container">
<img id="streamImg" src='' alt="">
</div>
<hr>
<form id="frm1">
Host <input type="text" id="host" value="photonvision.local"><br>
Port <input type="text" id="port" value="1181"><br>
</form>
<button>Start Stream</button>
<script type="module">
class WebsocketVideoStream{
constructor(drawDiv, streamPort, host) {
this.drawDiv = drawDiv;
this.image = document.getElementById(this.drawDiv);
this.streamPort = streamPort;
this.newStreamPortReq = null;
this.serverAddr = "ws://" + host + "/websocket_cameras";
this.dispNoStream();
this.ws_connect();
this.imgData = null;
this.imgDataTime = -1;
this.imgObjURL = null;
this.frameRxCount = 0;
//Display state machine
this.DSM_DISCONNECTED = "DISCONNECTED";
this.DSM_WAIT_FOR_VALID_PORT = "WAIT_FOR_VALID_PORT";
this.DSM_SUBSCRIBE = "SUBSCRIBE";
this.DSM_WAIT_FOR_FIRST_FRAME = "WAIT_FOR_FIRST_FRAME";
this.DSM_SHOWING = "SHOWING";
this.DSM_RESTART_UNSUBSCRIBE = "UNSUBSCRIBE";
this.DSM_RESTART_WAIT = "WAIT_BEFORE_SUBSCRIBE";
this.dsm_cur_state = this.DSM_DISCONNECTED;
this.dsm_prev_state = this.DSM_DISCONNECTED;
this.dsm_restart_start_time = window.performance.now();
requestAnimationFrame(()=>this.animationLoop());
}
dispImageData(){
//From https://stackoverflow.com/questions/67507616/set-image-src-from-image-blob/67507685#67507685
if(this.imgObjURL != null){
URL.revokeObjectURL(this.imgObjURL)
}
this.imgObjURL = URL.createObjectURL(this.imgData);
//Update the image with the new mimetype and image
this.image.src = this.imgObjURL;
}
dispNoStream() {
this.image.src = "loading.svg";
}
animationLoop(){
// Update time metrics
const now = window.performance.now();
const timeInState = now - this.dsm_restart_start_time;
// Save previous state
this.dsm_prev_state = this.dsm_cur_state;
// Evaluate state transitions
if(!this.serverConnectionActive){
//Any state - if the server connection goes false, always transition to disconnected
this.dsm_cur_state = this.DSM_DISCONNECTED;
} else {
//Conditional transitions
switch(this.dsm_cur_state) {
case this.DSM_DISCONNECTED:
//Immediately transition to waiting for the first frame
this.dsm_cur_state = this.DSM_WAIT_FOR_VALID_PORT;
break;
case this.DSM_WAIT_FOR_VALID_PORT:
// Wait until the user has configured a valid port
if(this.streamPort > 0){
this.dsm_cur_state = this.DSM_SUBSCRIBE;
} else {
this.dsm_cur_state = this.DSM_WAIT_FOR_VALID_PORT;
}
break;
case this.DSM_SUBSCRIBE:
// Immediately transition after subscriptions is sent
this.dsm_cur_state = this.DSM_WAIT_FOR_FIRST_FRAME;
break;
case this.DSM_WAIT_FOR_FIRST_FRAME:
if(this.imgData != null){
//we got some image data, start showing it
this.dsm_cur_state = this.DSM_SHOWING;
} else if (this.newStreamPortReq != null){
//Stream port requested changed, unsubscribe and restart
this.dsm_cur_state = this.DSM_RESTART_UNSUBSCRIBE;
} else {
this.dsm_cur_state = this.DSM_WAIT_FOR_FIRST_FRAME;
}
break;
case this.DSM_SHOWING:
if((now - this.imgDataTime) > 2500){
//timeout, begin the restart sequence
this.dsm_cur_state = this.DSM_RESTART_UNSUBSCRIBE;
} else if (this.newStreamPortReq != null){
//Stream port requested changed, unsubscribe and restart
this.dsm_cur_state = this.DSM_RESTART_UNSUBSCRIBE;
} else {
//stay in this state.
this.dsm_cur_state = this.DSM_SHOWING;
}
break;
case this.DSM_RESTART_UNSUBSCRIBE:
//Only should spend one loop in Unsubscribe, immediately transition
this.dsm_cur_state = this.DSM_RESTART_WAIT;
break;
case this.DSM_RESTART_WAIT:
if (timeInState > 250) {
//we've waited long enough, go to try to re-subscribe
this.dsm_cur_state = this.DSM_WAIT_FOR_VALID_PORT;
} else {
//stay in this state.
this.dsm_cur_state = this.DSM_RESTART_WAIT;
}
break;
default:
// Shouldn't get here, default back to init
this.dsm_cur_state = this.DSM_DISCONNECTED;
}
}
//take current-state or state-transition actions
if(this.dsm_cur_state !== this.dsm_prev_state){
//Any state transition
console.log("State Change: " + this.dsm_prev_state + " -> " + this.dsm_cur_state);
}
if(this.dsm_cur_state === this.DSM_SHOWING){
// Currently in SHOWING
this.dispImageData();
}
if(this.dsm_cur_state !== this.DSM_SHOWING && this.dsm_prev_state === this.DSM_SHOWING ){
//Any transition out of showing - no stream
this.dispNoStream();
}
if(this.dsm_cur_state === this.DSM_RESTART_UNSUBSCRIBE){
// Currently in UNSUBSCRIBE, do the unsubscribe actions
this.stopStream();
this.dsm_restart_start_time = now;
}
if(this.dsm_cur_state === this.DSM_SUBSCRIBE){
// Currently in SUBSCRIBE, do the subscribe actions
this.startStream();
this.dsm_restart_start_time = now;
}
if(this.dsm_cur_state === this.DSM_WAIT_FOR_VALID_PORT){
// Currently waiting for a valid port to be requested
if(this.newStreamPortReq != null){
this.streamPort = this.newStreamPortReq;
this.newStreamPortReq = null;
}
}
requestAnimationFrame(()=>this.animationLoop());
}
startStream() {
console.log("Subscribing to port " + this.streamPort);
this.imgData = null;
this.ws.send(JSON.stringify({"cmd": "subscribe", "port":this.streamPort}));
}
stopStream() {
console.log("Unsubscribing");
this.ws.send(JSON.stringify({"cmd": "unsubscribe"}));
this.imgData = null;
}
setPort(streamPort){
console.log("Port set to " + streamPort);
this.newStreamPortReq = streamPort;
}
ws_onOpen() {
// Set the flag allowing general server communication
this.serverConnectionActive = true;
console.log("Connected!");
}
ws_onClose(e) {
//Clear flags to stop server communication
this.ws = null;
this.serverConnectionActive = false;
console.log('Camera Socket is closed. Reconnect will be attempted in 0.5 second.', e.reason);
setTimeout(this.ws_connect.bind(this), 500);
if(!e.wasClean){
console.error('Socket encountered error!');
}
}
ws_onError(e){
e; //prevent unused failure
this.ws.close();
}
ws_onMessage(e){
if(typeof e.data === 'string'){
//string data from host
//TODO - anything to receive info here? Maybe "available streams?"
} else {
if(e.data.size > 0){
//binary data - a frame
this.imgData = e.data;
this.imgDataTime = window.performance.now();
this.frameRxCount++;
} else {
//TODO - server is sending empty frames?
}
}
}
ws_connect() {
this.serverConnectionActive = false;
this.ws = new WebSocket(this.serverAddr);
this.ws.binaryType = "blob";
this.ws.onopen = this.ws_onOpen.bind(this);
this.ws.onmessage = this.ws_onMessage.bind(this);
this.ws.onclose = this.ws_onClose.bind(this);
this.ws.onerror = this.ws_onError.bind(this);
console.log("Connecting to server " + this.serverAddr);
}
ws_close(){
this.ws.close();
}
}
let stream = null;
function streamStartRequest() {
const host = document.getElementById("host").value + ":5800";
const port = document.getElementById("port").value;
if(stream == null){
stream = new WebsocketVideoStream("streamImg",port,host);
} else {
stream.setPort(port);
}
}
// Attach listener
document.querySelector('button').addEventListener('click', streamStartRequest);
// Deal with URLParams, validating inputs
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
const port_in = urlParams.get('port')
const host_in = urlParams.get('host')
if(port_in !== ""){
document.getElementById("port").value = port_in;
}
if(host_in !== ""){
document.getElementById("host").value = host_in;
}
if(port_in !== "" && host_in !== ""){
streamStartRequest(); //we got valid inputs, auto-start the stream
}
</script>
</body>
</html>

View File

@@ -1,399 +1,84 @@
<script setup lang="ts">
import { useStateStore } from "@/stores/StateStore";
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { AutoReconnectingWebsocket } from "@/lib/AutoReconnectingWebsocket";
import { inject } from "vue";
import PhotonSidebar from "@/components/app/photon-sidebar.vue";
import PhotonLogView from "@/components/app/photon-log-view.vue";
import PhotonErrorSnackbar from "@/components/app/photon-error-snackbar.vue";
const websocket = new AutoReconnectingWebsocket(
`ws://${inject("backendHost")}/websocket_data`,
() => {
useStateStore().$patch({ backendConnected: true });
},
(data) => {
if(data.log !== undefined) {
useStateStore().addLogFromWebsocket(data.log);
}
if(data.settings !== undefined) {
useSettingsStore().updateGeneralSettingsFromWebsocket(data.settings);
}
if(data.cameraSettings !== undefined) {
useCameraSettingsStore().updateCameraSettingsFromWebsocket(data.cameraSettings);
}
if(data.ntConnectionInfo !== undefined) {
useStateStore().updateNTConnectionStatusFromWebsocket(data.ntConnectionInfo);
}
if(data.metrics !== undefined) {
useSettingsStore().updateMetricsFromWebsocket(data.metrics);
}
if(data.updatePipelineResult !== undefined) {
useStateStore().updatePipelineResultsFromWebsocket(data.updatePipelineResult);
}
if(data.mutatePipelineSettings !== undefined && data.cameraIndex !== undefined) {
useCameraSettingsStore().changePipelineSettingsInStore(data.mutatePipelineSettings, data.cameraIndex);
}
if(data.calibrationData !== undefined) {
useStateStore().updateCalibrationStateValuesFromWebsocket(data.calibrationData);
}
},
() => {
useStateStore().$patch({ backendConnected: false });
}
);
useStateStore().$patch({ websocket: websocket });
</script>
<template>
<v-app>
<!-- Although most of the app runs with the "light" theme, the navigation drawer needs to have white text and icons so it uses the dark theme-->
<v-navigation-drawer
dark
app
permanent
:mini-variant="compact"
color="primary"
>
<v-list>
<!-- List item for the heading; note that there are some tricks in setting padding and image width make things look right -->
<v-list-item
:class="compact ? 'pr-0 pl-0' : ''"
style="display: flex; justify-content: center"
>
<v-list-item-icon class="mr-0">
<img
v-if="!compact"
class="logo"
src="@/assets/logos/logoLarge.svg"
alt="large logo"
>
<img
v-else
class="logo"
src="@/assets/logos/logoSmall.svg"
alt="small logo"
>
</v-list-item-icon>
</v-list-item>
<v-list-item
link
to="/dashboard"
@click="rollbackPipelineIndex()"
>
<v-list-item-icon>
<v-icon>mdi-view-dashboard</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Dashboard</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item
ref="camerasTabOpener"
link
to="/cameras"
@click="switchToDriverMode()"
>
<v-list-item-icon>
<v-icon>mdi-camera</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Cameras</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item
link
to="/settings"
@click="switchToSettingsTab()"
>
<v-list-item-icon>
<v-icon>mdi-cog</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Settings</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item
link
to="/docs"
>
<v-list-item-icon>
<v-icon>mdi-bookshelf</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Documentation</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item
v-if="this.$vuetify.breakpoint.mdAndUp"
link
@click.stop="toggleCompactMode"
>
<v-list-item-icon>
<v-icon v-if="compact">
mdi-chevron-right
</v-icon>
<v-icon v-else>
mdi-chevron-left
</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Compact Mode</v-list-item-title>
</v-list-item-content>
</v-list-item>
<div style="position: absolute; bottom: 0; left: 0;">
<v-list-item>
<v-list-item-icon>
<v-icon v-if="$store.state.settings.networkSettings.runNTServer">
mdi-server
</v-icon>
<v-icon v-else-if="$store.state.ntConnectionInfo.connected">
mdi-robot
</v-icon>
<v-icon
v-else
style="border-radius: 100%"
>
mdi-robot-off
</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title
v-if="$store.state.settings.networkSettings.runNTServer"
class="text-wrap"
>
NetworkTables server running for <span class="accent--text">{{ $store.state.ntConnectionInfo.clients }}</span> clients
</v-list-item-title>
<v-list-item-title
v-else-if="$store.state.ntConnectionInfo.connected && $store.state.backendConnected"
class="text-wrap"
style="flex-direction: column; display: flex"
>
NetworkTables Server Connected!
<span
class="accent--text"
>
{{ $store.state.ntConnectionInfo.address }}
</span>
</v-list-item-title>
<v-list-item-title
v-else
class="text-wrap"
style="flex-direction: column; display: flex"
>
Not connected to NetworkTables Server!
</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-icon>
<v-icon v-if="$store.state.backendConnected">
mdi-server-network
</v-icon>
<v-icon
v-else
style="border-radius: 100%;"
>
mdi-server-network-off
</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title class="text-wrap">
{{ $store.state.backendConnected ? "Backend Connected" : "Trying to connect to Backend" }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</div>
</v-list>
</v-navigation-drawer>
<photon-sidebar />
<v-main>
<v-container
class="main-container"
fluid
fill-height
>
<v-layout>
<v-flex>
<router-view @switch-to-cameras="switchToDriverMode" />
<router-view />
</v-flex>
</v-layout>
</v-container>
</v-main>
<v-dialog
v-model="$store.state.logsOverlay"
width="1500"
dark
>
<logs />
</v-dialog>
<v-dialog
v-model="needsTeamNumberSet"
width="500"
dark
persistent
>
<v-card
dark
color="primary"
flat
>
<v-card-title>NetworkTables Server Address Not Set</v-card-title>
<v-card-text>
PhotonVision cannot connect to the NetworkTables Server. Please visit the
<router-link
to="/settings"
class="accent--text"
@click="switchToSettingsTab"
>
networking settings tab
</router-link>
and set the NetworkTables Server Address.
</v-card-text>
</v-card>
</v-dialog>
<photon-log-view />
<photon-error-snackbar />
</v-app>
</template>
<script>
import Logs from "./views/LogsView"
import { ReconnectingWebsocket } from "./plugins/ReconnectingWebsocket.js"
<style lang="scss">
@import 'vuetify/src/styles/settings/_variables';
export default {
name: 'App',
components: {
Logs
},
data: () => ({
// Used so that we can switch back to the previously selected pipeline after camera calibration
previouslySelectedIndices: [],
timer: undefined,
teamNumberDialog: true,
websocket: null,
}),
computed: {
needsTeamNumberSet: {
get() {
return this.$store.state.settings.networkSettings.ntServerAddress == ""
&& this.teamNumberDialog && this.$store.state.backendConnected
&& !this.$route.name.toLowerCase().includes("settings");
}
},
compact: {
get() {
if (this.$store.state.compactMode === undefined) {
return this.$vuetify.breakpoint.smAndDown;
} else {
return this.$store.state.compactMode || this.$vuetify.breakpoint.smAndDown;
}
},
set(value) {
// compactMode is the user's preference for compact mode; it overrides screen size
this.$store.commit("compactMode", value);
localStorage.setItem("compactMode", value);
},
},
},
created() {
document.addEventListener("keydown", e => {
switch (e.key) {
case "`":
this.$store.state.logsOverlay = !this.$store.state.logsOverlay;
break;
case "z":
if (e.ctrlKey && this.$store.getters.canUndo) {
this.$store.dispatch('undo', { vm: this });
}
break;
case "y":
if (e.ctrlKey && this.$store.getters.canRedo) {
this.$store.dispatch('redo', { vm: this });
}
break;
}
});
const wsDataURL = 'ws://' + this.$address + '/websocket_data';
this.websocket = new ReconnectingWebsocket(
wsDataURL,
// On data in
(event) => {
try {
let message = this.$msgPack.decode(event.data);
for (let prop in message) {
if (message.hasOwnProperty(prop)) {
this.handleMessage(prop, message[prop]);
}
}
} catch (error) {
console.log(event)
console.error('error: ' + JSON.stringify(event.data) + " , " + error);
}
},
// on connect
(event) => {
event; this.$store.commit("backendConnected", true);
this.$store.state.connectedCallbacks.forEach(it => it());
},
// on disconnect
(event) => { event; this.$store.commit("backendConnected", false) }
);
this.$store.commit("websocket", this.websocket);
},
methods: {
handleMessage(key, value) {
if (key === "logMessage") {
this.logMessage(value["logMessage"], value["logLevel"]);
} else if (key === "log") {
this.logMessage(value["logMessage"]["logMessage"], value["logMessage"]["logLevel"]);
} else if (key === "updatePipelineResult") {
this.$store.commit('mutatePipelineResults', value)
} else if (this.$store.state.hasOwnProperty(key)) {
this.$store.commit(key, value);
} else if (this.$store.getters.currentPipelineSettings.hasOwnProperty(key)) {
this.$store.commit('mutatePipeline', { [key]: value });
} else if (this.$store.state.settings.hasOwnProperty(key)) {
this.$store.commit('mutateSettings', { [key]: value });
} else {
console.error("Unknown message from backend: " + value);
}
},
toggleCompactMode() {
this.compact = !this.compact;
},
// eslint-disable-next-line no-unused-vars
logMessage(message, levelInt) {
this.$store.commit('logString', {
['level']: levelInt,
['message']: message
})
},
switchToDriverMode() {
if (!this.previouslySelectedIndices) this.previouslySelectedIndices = [];
for (const [i, cameraSettings] of this.$store.state.cameraSettings.entries()) {
this.previouslySelectedIndices[i] = cameraSettings.currentPipelineIndex;
this.handleInputWithIndex('currentPipeline', -1, i);
}
},
rollbackPipelineIndex() {
if (this.previouslySelectedIndices !== null) {
for (const [i] of this.$store.state.cameraSettings.entries()) {
this.handleInputWithIndex('currentPipeline', this.previouslySelectedIndices[i] || 0, i);
}
}
this.previouslySelectedIndices = null;
},
switchToSettingsTab() {
this.axios.post('http://' + this.$address + '/api/utils/publishMetrics')
}
}
};
</script>
<style lang="sass">
@import "./scss/variables.scss"
</style>
<style>
.pulse {
animation: pulse-animation 2s infinite;
}
@keyframes pulse-animation {
0% {
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.2);
background-color: rgba(0, 0, 0, 0.2);
}
100% {
box-shadow: 0 0 0 20px rgba(0, 0, 0, 0);
background-color: rgba(0, 0, 0, 0);
@media #{map-get($display-breakpoints, 'md-and-down')} {
html {
font-size: 14px !important;
}
}
.logo {
width: 100%;
height: 70px;
object-fit: contain;
}
::-webkit-scrollbar {
width: 0.5em;
border-radius: 5px;
}
::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background-color: #ffd843;
border-radius: 10px;
}
.container {
.main-container {
background-color: #232c37;
padding: 0 !important;
}
@@ -402,30 +87,3 @@ export default {
color: #ffd843;
}
</style>
<style>
/* Hacks */
.v-divider {
border-color: white !important;
}
.v-input {
font-size: 1rem !important;
}
/* This is unfortunately the only way to override table background color */
.theme--dark.v-data-table>.v-data-table__wrapper>table>tbody>tr:hover:not(.v-data-table__expanded__content):not(.v-data-table__empty-wrapper) {
background: #005281 !important;
}
</style>
<style lang="scss">
@import '~vuetify/src/styles/settings/_variables';
@media #{map-get($display-breakpoints, 'md-and-down')} {
html {
font-size: 14px !important;
}
}
</style>

File diff suppressed because one or more lines are too long

View File

@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>eyedropper</title>
<path
d="M19.35,11.72L17.22,13.85L15.81,12.43L8.1,20.14L3.5,22L2,20.5L3.86,15.9L11.57,8.19L10.15,6.78L12.28,4.65L19.35,11.72M16.76,3C17.93,1.83 19.83,1.83 21,3C22.17,4.17 22.17,6.07 21,7.24L19.08,9.16L14.84,4.92L16.76,3M5.56,17.03L4.5,19.5L6.97,18.44L14.4,11L13,9.6L5.56,17.03Z"
stroke="black"
stroke-width="0.5"
fill="#FFFFFF"
transform="rotate(90, 12, 12)"
/>
</svg>

Before

Width:  |  Height:  |  Size: 492 B

View File

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -0,0 +1,9 @@
@import "@fontsource/prompt";
$default-font: "Prompt", sans-serif !default;
$body-font-family: $default-font;
$heading-font-family: $default-font;
.v-application {
font-family: $default-font !important;
}

View File

@@ -0,0 +1,226 @@
<script setup lang="ts">
import type { PhotonTarget } from "@/types/PhotonTrackingTypes";
import { onBeforeUnmount, onMounted, watchEffect } from "vue";
import {
ArrowHelper,
BoxGeometry,
ConeGeometry,
Mesh,
MeshNormalMaterial,
PerspectiveCamera,
Quaternion,
Scene,
Vector3,
Color,
WebGLRenderer
} from "three";
import { TrackballControls } from "three/examples/jsm/controls/TrackballControls";
import { type Object3D } from "three";
const props = defineProps<{
targets: PhotonTarget[]
}>();
let scene: Scene | undefined;
let camera: PerspectiveCamera | undefined;
let renderer: WebGLRenderer | undefined;
let controls: TrackballControls | undefined;
let previousTargets: Object3D[] = [];
const drawTargets = (targets: PhotonTarget[]) => {
// Check here, since if we check in watchEffect this never gets called
if(scene === undefined || camera === undefined || renderer === undefined || controls === undefined) {
return;
}
scene.remove(...previousTargets);
previousTargets = [];
targets.forEach(target => {
if(target.pose === undefined) return;
const geometry = new BoxGeometry(0.3 / 5, 0.2, 0.2);
const material = new MeshNormalMaterial();
const quaternion = new Quaternion(
target.pose.qx,
target.pose.qy,
target.pose.qz,
target.pose.qw
);
const cube = new Mesh(geometry, material);
cube.position.set(target.pose.x, target.pose.y, target.pose.z);
cube.rotation.setFromQuaternion(quaternion);
previousTargets.push(cube);
let arrow = new ArrowHelper(new Vector3(1, 0, 0).normalize(), new Vector3(0, 0, 0), 1, 0xff0000, 0.1, 0.1);
arrow.rotation.setFromQuaternion(quaternion);
arrow.rotateZ(-Math.PI / 2);
arrow.position.set(target.pose.x, target.pose.y, target.pose.z);
previousTargets.push(arrow);
arrow = new ArrowHelper(new Vector3(1, 0, 0).normalize(), new Vector3(0, 0, 0), 1, 0x00ff00, 0.1, 0.1);
arrow.rotation.setFromQuaternion(quaternion);
arrow.position.set(target.pose.x, target.pose.y, target.pose.z);
previousTargets.push(arrow);
arrow = new ArrowHelper(new Vector3(1, 0, 0).normalize(), new Vector3(0, 0, 0), 1, 0x0000ff, 0.1, 0.1);
arrow.setRotationFromQuaternion(quaternion);
arrow.rotateX(Math.PI / 2);
arrow.position.set(target.pose.x, target.pose.y, target.pose.z);
previousTargets.push(arrow);
});
if(previousTargets.length > 0) {
scene.add(...previousTargets);
}
};
const onWindowResize = () => {
const container = document.getElementById("container");
const canvas = document.getElementById("view");
if(container === null || canvas === null || camera === undefined || renderer === undefined) {
return;
}
canvas.style.width = container.clientWidth * 0.75 + "px";
canvas.style.height = container.clientWidth * 0.35 + "px";
camera.aspect = canvas.clientWidth / canvas.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(canvas.clientWidth, canvas.clientHeight);
};
const resetCamFirstPerson = () => {
if(scene === undefined || camera === undefined || controls === undefined) {
return;
}
controls.reset();
camera.position.set(0.2, 0, 0);
camera.up.set(0, 0, 1);
controls.target.set(4.0, 0.0, 0.0);
controls.update();
if(previousTargets.length > 0) {
scene.add(...previousTargets);
}
};
const resetCamThirdPerson = () => {
if(scene === undefined || camera === undefined || controls === undefined) {
return;
}
controls.reset();
camera.position.set(-1.39, -1.09, 1.17);
camera.up.set(0, 0, 1);
controls.target.set(4.0, 0.0, 0.0);
controls.update();
if(previousTargets.length > 0) {
scene.add(...previousTargets);
}
};
onMounted(() => {
scene = new Scene();
camera = new PerspectiveCamera(75, 800 / 800, 0.1, 1000);
const canvas = document.getElementById("view");
if(canvas === null) return;
renderer = new WebGLRenderer({ canvas: canvas });
scene.background = new Color(0xa9a9a9);
onWindowResize();
window.addEventListener("resize", onWindowResize);
const referenceFrameCues: Object3D[] = [];
referenceFrameCues.push(new ArrowHelper(new Vector3(1, 0, 0).normalize(), new Vector3(0, 0, 0), 1, 0xff0000, 0.1, 0.1));
referenceFrameCues.push(new ArrowHelper(new Vector3(0, 1, 0).normalize(), new Vector3(0, 0, 0), 1, 0x00ff00, 0.1, 0.1));
referenceFrameCues.push(new ArrowHelper(new Vector3(0, 0, 1).normalize(), new Vector3(0, 0, 0), 1, 0x0000ff, 0.1, 0.1));
// Draw the Camera Body
const camSize = 0.2;
const camBodyGeometry = new BoxGeometry(camSize, camSize, camSize);
const camLensGeometry = new ConeGeometry(camSize*0.4, camSize*0.8, 30);
const camMaterial = new MeshNormalMaterial();
const camBody = new Mesh(camBodyGeometry, camMaterial);
const camLens = new Mesh(camLensGeometry, camMaterial);
camBody.position.set(0, 0, 0);
camLens.rotateZ(Math.PI / 2);
camLens.position.set(camSize * 0.8, 0, 0);
referenceFrameCues.push(camBody);
referenceFrameCues.push(camLens);
controls = new TrackballControls(
camera,
renderer.domElement
);
controls.rotateSpeed = 1.0;
controls.zoomSpeed = 1.2;
controls.panSpeed = 0.8;
controls.noZoom = false;
controls.noPan = false;
controls.staticMoving = true;
controls.dynamicDampingFactor = 0.3;
scene.add(...referenceFrameCues);
resetCamThirdPerson();
controls.update();
const animate = () => {
if(scene === undefined || camera === undefined || renderer === undefined || controls === undefined) {
return;
}
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
drawTargets(props.targets);
animate();
});
onBeforeUnmount(() => {
window.removeEventListener("resize", onWindowResize);
});
watchEffect(() => {
drawTargets(props.targets);
});
</script>
<template>
<div
id="container"
style="width: 100%"
>
<v-row>
<v-col
align-self="stretch"
style="display: flex; justify-content: center"
>
<canvas
id="view"
/>
</v-col>
</v-row>
<v-row style="margin-bottom: 24px">
<v-col style="display: flex; justify-content: center">
<v-btn
color="secondary"
@click="resetCamFirstPerson"
>
First Person
</v-btn>
</v-col>
<v-col style="display: flex; justify-content: center">
<v-btn
color="secondary"
@click="resetCamThirdPerson"
>
Third Person
</v-btn>
</v-col>
</v-row>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed, inject } from "vue";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { useStateStore } from "@/stores/StateStore";
import loadingImage from "@/assets/images/loading.svg";
import type { StyleValue } from "vue/types/jsx";
const props = defineProps<{
streamType: "Raw" | "Processed",
id?: string
}>();
const src = computed<string>(() => {
const port = useCameraSettingsStore().currentCameraSettings.stream[props.streamType === "Raw" ? "inputPort" : "outputPort"];
if(!useStateStore().backendConnected || port === 0) {
return loadingImage;
}
return `http://${inject("backendHostname")}:${port}/stream.mjpg`;
});
const alt = computed<string>(() => `${props.streamType} Stream View`);
const style = computed<StyleValue>(() => {
if(useStateStore().colorPickingMode) {
return { cursor: "crosshair" };
} else if(src.value !== loadingImage) {
return { cursor: "pointer" };
}
return {};
});
const handleClick = () => {
if(!useStateStore().colorPickingMode && src.value !== loadingImage) {
window.open(src.value);
}
};
</script>
<template>
<img
:id="id"
crossorigin="anonymous"
:src="src"
:alt="alt"
:style="style"
@click="handleClick"
>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { useStateStore } from "@/stores/StateStore";
</script>
<template>
<v-snackbar
v-model="useStateStore().snackbarData.show"
top
:color="useStateStore().snackbarData.color"
:timeout="useStateStore().snackbarData.timeout"
>
<p style="padding: 0; margin: 0; text-align: center">
{{ useStateStore().snackbarData.message }}
</p>
</v-snackbar>
</template>

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
import { computed, ref, inject } from "vue";
import { LogLevel, type LogMessage } from "@/types/SettingTypes";
import { useStateStore } from "@/stores/StateStore";
const selectedLogLevels = ref<LogLevel[]>([LogLevel.ERROR, LogLevel.WARN, LogLevel.INFO]);
const logs = computed<LogMessage[]>(() => useStateStore().logMessages.filter(message => selectedLogLevels.value.includes(message.level)));
const backendHost = inject<string>("backendHost");
const getLogColor = (level: LogLevel): string => {
switch (level) {
case LogLevel.ERROR:
return "red";
case LogLevel.WARN:
return "yellow";
case LogLevel.INFO:
return "green";
case LogLevel.DEBUG:
return "white";
}
return "";
};
const getLogLevelFromIndex = (index: number): string => {
return LogLevel[index];
};
const exportLogFile = ref();
const handleLogExport = () => {
exportLogFile.value.click();
};
document.addEventListener("keydown", e => {
switch (e.key) {
case "`":
useStateStore().$patch(state => state.showLogModal = !state.showLogModal);
break;
}
});
</script>
<template>
<v-dialog
v-model="useStateStore().showLogModal"
width="1500"
dark
>
<v-card
dark
class="pt-3"
color="primary"
flat
>
<v-card-title>
View Program Logs
<v-btn
color="secondary"
style="margin-left: auto;"
depressed
@click="handleLogExport"
>
<v-icon left>
mdi-download
</v-icon>
Download Current Log
<!-- Special hidden link that gets 'clicked' when the user exports journalctl logs -->
<a
ref="exportLogFile"
style="color: black; text-decoration: none; display: none"
:href="`http://${backendHost}/api/utils/photonvision-journalctl.txt`"
download="photonvision-journalctl.txt"
target="_blank"
/>
</v-btn>
</v-card-title>
<div class="pr-6 pl-6">
<v-btn-toggle
v-model="selectedLogLevels"
dark
multiple
class="fill mb-4"
>
<v-btn
v-for="(level) in [0, 1, 2, 3]"
:key="level"
color="secondary"
class="fill"
>
{{ getLogLevelFromIndex(level) }}
</v-btn>
</v-btn-toggle>
<v-card-text
v-if="logs.length === 0"
style="font-size: 18px; font-weight: 600"
>
There are no Logs to show
</v-card-text>
<v-virtual-scroll
v-else
:items="logs"
item-height="50"
height="600"
>
<template #default="{item}">
<div :class="[getLogColor(item.level) + '--text', 'log-item']">
{{ item.message }}
</div>
</template>
</v-virtual-scroll>
</div>
<v-divider />
<v-card-actions>
<v-spacer />
<v-btn
color="white"
text
@click="() => useStateStore().showLogModal = false"
>
Close
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.v-btn-toggle.fill {
width: 100%;
height: 100%;
}
.v-btn-toggle.fill > .v-btn {
width: 25%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import { computed, getCurrentInstance } from "vue";
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
import { useStateStore } from "@/stores/StateStore";
const compact = computed<boolean>({
get: () => { return useStateStore().sidebarFolded; },
set: (val) => { useStateStore().setSidebarFolded(val); }
});
// Vuetify2 doesn't yet support the useDisplay API so this is required to access the prop when using the Composition API
const mdAndUp = computed<boolean>(() => getCurrentInstance()?.proxy.$vuetify.breakpoint.mdAndUp || false);
</script>
<template>
<v-navigation-drawer
dark
app
permanent
:mini-variant="compact || !mdAndUp"
color="primary"
>
<v-list>
<!-- List item for the heading; note that there are some tricks in setting padding and image width make things look right -->
<v-list-item
:class="(compact || !mdAndUp) ? 'pr-0 pl-0' : ''"
style="display: flex; justify-content: center"
>
<v-list-item-icon class="mr-0">
<img
v-if="!(compact || !mdAndUp)"
class="logo"
src="@/assets/images/logoLarge.svg"
alt="large logo"
>
<img
v-else
class="logo"
src="@/assets/images/logoSmall.svg"
alt="small logo"
>
</v-list-item-icon>
</v-list-item>
<v-list-item
link
to="/dashboard"
>
<v-list-item-icon>
<v-icon>mdi-view-dashboard</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Dashboard</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item
ref="camerasTabOpener"
link
to="/cameras"
>
<v-list-item-icon>
<v-icon>mdi-camera</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Cameras</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item
link
to="/settings"
>
<v-list-item-icon>
<v-icon>mdi-cog</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Settings</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item
link
to="/docs"
>
<v-list-item-icon>
<v-icon>mdi-bookshelf</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Documentation</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item
v-if="mdAndUp"
link
@click="() => compact = !compact"
>
<v-list-item-icon>
<v-icon v-if="compact || !mdAndUp">
mdi-chevron-right
</v-icon>
<v-icon v-else>
mdi-chevron-left
</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Compact Mode</v-list-item-title>
</v-list-item-content>
</v-list-item>
<div style="position: absolute; bottom: 0; left: 0;">
<v-list-item>
<v-list-item-icon>
<v-icon v-if="useSettingsStore().network.runNTServer">
mdi-server
</v-icon>
<v-icon v-else-if="useStateStore().ntConnectionStatus.connected">
mdi-robot
</v-icon>
<v-icon
v-else
style="border-radius: 100%"
>
mdi-robot-off
</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title
v-if="useSettingsStore().network.runNTServer"
class="text-wrap"
>
NetworkTables server running for <span class="accent--text">{{ useStateStore().ntConnectionStatus.clients }}</span> clients
</v-list-item-title>
<v-list-item-title
v-else-if="useStateStore().ntConnectionStatus.connected && useStateStore().backendConnected"
class="text-wrap"
style="flex-direction: column; display: flex"
>
NetworkTables Server Connected!
<span
class="accent--text"
>
{{ useStateStore().ntConnectionStatus.address }}
</span>
</v-list-item-title>
<v-list-item-title
v-else
class="text-wrap"
style="flex-direction: column; display: flex"
>
Not connected to NetworkTables Server!
</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-icon>
<v-icon v-if="useStateStore().backendConnected">
mdi-server-network
</v-icon>
<v-icon
v-else
style="border-radius: 100%;"
>
mdi-server-network-off
</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title class="text-wrap">
{{ useStateStore().backendConnected ? "Backend Connected" : "Trying to connect to Backend" }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</div>
</v-list>
</v-navigation-drawer>
</template>
<style scoped>
.logo {
width: 100%;
height: 70px;
object-fit: contain;
}
</style>

View File

@@ -0,0 +1,550 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { CalibrationBoardTypes, type VideoFormat, type Resolution } from "@/types/SettingTypes";
import JsPDF from "jspdf";
import { font as PromptRegular } from "@/assets/fonts/PromptRegular";
import MonoLogo from "@/assets/images/logoMono.png";
import CvSlider from "@/components/common/cv-slider.vue";
import { useStateStore } from "@/stores/StateStore";
import CvSwitch from "@/components/common/cv-switch.vue";
import CvSelect from "@/components/common/cv-select.vue";
import CvNumberInput from "@/components/common/cv-number-input.vue";
import { WebsocketPipelineType } from "@/types/WebsocketDataTypes";
const settingsValid = ref(true);
const getCalibrationCoeffs = (resolution: Resolution) => {
return useCameraSettingsStore().currentCameraSettings.completeCalibrations.find(cal => cal.resolution.width === resolution.width && cal.resolution.height === resolution.height);
};
const getUniqueVideoResolutions = (): VideoFormat[] => {
const uniqueResolutions: VideoFormat[] = [];
useCameraSettingsStore().currentCameraSettings.validVideoFormats.forEach((format, index) => {
if(!uniqueResolutions.some(v => v.resolution.width === format.resolution.width && v.resolution.height === format.resolution.height)) {
format.index = index;
const calib = getCalibrationCoeffs(format.resolution);
if(calib !== undefined) {
format.standardDeviation = calib.standardDeviation;
format.mean = calib.perViewErrors.reduce((a, b) => a + b) / calib.perViewErrors.length;
format.horizontalFOV = 2 * Math.atan2(format.resolution.width/2, calib.intrinsics[0]) * (180/Math.PI);
format.verticalFOV = 2 * Math.atan2(format.resolution.height/2, calib.intrinsics[4]) * (180/Math.PI);
format.diagonalFOV = 2 * Math.atan2(Math.sqrt(format.resolution.width**2 + (format.resolution.height/(calib.intrinsics[4]/calib.intrinsics[0]))**2)/2, calib.intrinsics[0]) * (180/Math.PI);
}
uniqueResolutions.push(format);
}
});
uniqueResolutions.sort((a, b) => (b.resolution.width + b.resolution.height) - (a.resolution.width + a.resolution.height));
return uniqueResolutions;
};
const getUniqueVideoResolutionStrings = () => getUniqueVideoResolutions().map<{name: string, value: number}>(f => ({
name: `${f.resolution.width} X ${f.resolution.height}`,
// Index won't ever be undefined
value: f.index || 0
}));
const calibrationDivisors = computed(() => [1, 2, 4].filter(v => {
const currentRes = useCameraSettingsStore().currentVideoFormat.resolution;
return ((currentRes.width / v) >= 300 && (currentRes.height / v) >= 220) || (v === 1);
}));
const squareSizeIn = ref(1);
const patternWidth = ref(8);
const patternHeight = ref(8);
const boardType = ref<CalibrationBoardTypes>(CalibrationBoardTypes.Chessboard);
const downloadCalibBoard = () => {
const doc = new JsPDF({ unit: "in", format: "letter" });
doc.addFileToVFS("Prompt-Regular.tff", PromptRegular);
doc.addFont("Prompt-Regular.tff", "Prompt-Regular", "normal");
doc.setFont("Prompt-Regular");
doc.setFontSize(12);
const paperWidth = 8.5;
const paperHeight = 11.0;
switch (boardType.value) {
case CalibrationBoardTypes.Chessboard:
// eslint-disable-next-line no-case-declarations
const chessboardStartX = (paperWidth - patternWidth.value * squareSizeIn.value) / 2;
// eslint-disable-next-line no-case-declarations
const chessboardStartY = (paperHeight - patternWidth.value * squareSizeIn.value) / 2;
for (let squareY = 0; squareY < patternHeight.value; squareY++) {
for (let squareX = 0; squareX < patternWidth.value; squareX++) {
const xPos = chessboardStartX + squareX * squareSizeIn.value;
const yPos = chessboardStartY + squareY * squareSizeIn.value;
// Only draw the odd squares to create the chessboard pattern
if ((xPos + yPos + 0.25) % 2 === 0) {
doc.rect(xPos, yPos, squareSizeIn.value, squareSizeIn.value, "F");
}
}
}
break;
case CalibrationBoardTypes.DotBoard:
// eslint-disable-next-line no-case-declarations
const dotgridStartX = (paperWidth - (2 * (patternWidth.value - 1) + ((patternHeight.value - 1) % 2)) * squareSizeIn.value) / 2.0;
// eslint-disable-next-line no-case-declarations
const dotgridStartY = (paperHeight - (patternHeight.value - squareSizeIn.value)) / 2;
for (let squareY = 0; squareY < patternHeight.value; squareY++) {
for (let squareX = 0; squareX < patternWidth.value; squareX++) {
const xPos = dotgridStartX + (2 * squareX + (squareY % 2)) * squareSizeIn.value;
const yPos = dotgridStartY + squareY * squareSizeIn.value;
doc.circle(xPos, yPos, squareSizeIn.value / 4, "F");
}
}
break;
}
// Draw ruler pattern
const lineStartX = 1.0;
const lineEndX = paperWidth - lineStartX;
const lineY = paperHeight - 1.0;
doc.setLineWidth(0.01);
doc.line(lineStartX, lineY, lineEndX, lineY);
for (let tickX = lineStartX; tickX <= lineEndX; tickX++) {
doc.line(tickX, lineY, tickX, lineY + 0.25);
doc.text(`${tickX - 1}${tickX - 1 === 0 ? " in" : ""}`, tickX + 0.1, lineY + 0.25);
}
// Add branding
const logoImage = new Image();
logoImage.src = MonoLogo;
doc.addImage(logoImage, "PNG", 1.0, 0.75, 1.4, 0.5);
doc.text(`${patternWidth.value} x ${patternHeight.value} | ${squareSizeIn.value}in`, paperWidth - 1, 1.0,
{
maxWidth: (paperWidth - 2.0) / 2,
align: "right"
}
);
doc.save(`calibrationTarget-${CalibrationBoardTypes[boardType.value]}.pdf`);
};
const importCalibrationFromCalibDB = ref();
const openCalibUploadPrompt = () => {
importCalibrationFromCalibDB.value.click();
};
const readImportedCalibration = ({ files } : { files: FileList}) => {
files[0].text().then(text => {
useCameraSettingsStore().importCalibDB({ payload: text, filename: files[0].name })
.then((response) => {
useStateStore().showSnackbarMessage({
message: response.data.text || response.data,
color: response.status === 200 ? "success" : "error"
});
})
.catch(err => {
if (err.request) {
useStateStore().showSnackbarMessage({
message: "Error while uploading calibration file! The backend didn't respond to the upload attempt.",
color: "error"
});
} else {
useStateStore().showSnackbarMessage({
message: "Error while uploading calibration file!",
color: "error"
});
}
});
});
};
const isCalibrating = ref(false);
const startCalibration = () => {
useCameraSettingsStore().startPnPCalibration({
squareSizeIn: squareSizeIn.value,
patternHeight: patternHeight.value,
patternWidth: patternWidth.value,
boardType: boardType.value
});
// The Start PnP method already handles updating the backend so only a store update is required
useCameraSettingsStore().currentCameraSettings.currentPipelineIndex = WebsocketPipelineType.Calib3d;
isCalibrating.value = true;
calibCanceled.value = false;
};
const showCalibEndDialog = ref(false);
const calibCanceled = ref(false);
const calibSuccess = ref<boolean | undefined>(undefined);
const endCalibration = () => {
if(!useStateStore().calibrationData.hasEnoughImages) {
calibCanceled.value = true;
}
showCalibEndDialog.value = true;
// Check if calibration finished cleanly or was canceled
useCameraSettingsStore().endPnPCalibration()
.then(() => {
calibSuccess.value = true;
})
.catch(() => {
calibSuccess.value = false;
})
.finally(() => {
isCalibrating.value = false;
});
};
</script>
<template>
<div>
<v-card
class="pr-6 pb-3"
color="primary"
dark
>
<v-card-title>Camera Calibration</v-card-title>
<div class="ml-5">
<v-row>
<v-col
cols="12"
md="6"
>
<v-form
ref="form"
v-model="settingsValid"
>
<cv-select
v-model="useStateStore().calibrationData.videoFormatIndex"
label="Resolution"
:select-cols="7"
:disabled="isCalibrating"
tooltip="Resolution to calibrate at (you will have to calibrate every resolution you use 3D mode on)"
:items="getUniqueVideoResolutionStrings()"
/>
<cv-select
v-model="useCameraSettingsStore().currentPipelineSettings.streamingFrameDivisor"
label="Decimation"
tooltip="Resolution to which camera frames are downscaled for detection. Calibration still uses full-res"
:items="calibrationDivisors"
:select-cols="7"
@input="v => useCameraSettingsStore().changeCurrentPipelineSetting({streamingFrameDivisor: v}, false)"
/>
<cv-select
v-model="boardType"
label="Board Type"
tooltip="Calibration board pattern to use"
:select-cols="7"
:items="['Chessboard', 'Dotboard']"
:disabled="isCalibrating"
/>
<cv-number-input
v-model="squareSizeIn"
label="Pattern Spacing (in)"
tooltip="Spacing between pattern features in inches"
:disabled="isCalibrating"
:rules="[v => (v > 0) || 'Size must be positive']"
:label-cols="5"
/>
<cv-number-input
v-model="patternWidth"
label="Board Width (in)"
tooltip="Width of the board in dots or chessboard squares"
:disabled="isCalibrating"
:rules="[v => (v >= 4) || 'Width must be at least 4']"
:label-cols="5"
/>
<cv-number-input
v-model="patternHeight"
label="Board Height (in)"
tooltip="Height of the board in dots or chessboard squares"
:disabled="isCalibrating"
:rules="[v => (v >= 4) || 'Height must be at least 4']"
:label-cols="5"
/>
</v-form>
</v-col>
<v-col
cols="12"
md="6"
>
<v-row
align="start"
class="pb-4 pt-2"
>
<v-simple-table
fixed-header
height="100%"
dense
>
<thead>
<tr>
<th>
Resolution
</th>
<th>
Mean Error
</th>
<th>
Standard Deviation
</th>
<th>
Horizontal FOV
</th>
<th>
Vertical FOV
</th>
<th>
Diagonal FOV
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(value, index) in getUniqueVideoResolutions()"
:key="index"
>
<td>{{ value.resolution.width }} X {{ value.resolution.height }}</td>
<td>{{ value.mean !== undefined ? value.mean.toFixed(2) + "px" : "-" }}</td>
<td>{{ value.standardDeviation !== undefined ? value.standardDeviation.toFixed(2) + "px" : "-" }}</td>
<td>{{ value.horizontalFOV !== undefined ? value.horizontalFOV.toFixed(2) + "°" : "-" }}</td>
<td>{{ value.verticalFOV !== undefined ? value.verticalFOV.toFixed(2) + "°" : "-" }}</td>
<td>{{ value.diagonalFOV !== undefined ? value.diagonalFOV.toFixed(2) + "°" : "-" }}</td>
</tr>
</tbody>
</v-simple-table>
</v-row>
<v-row justify="center">
<v-chip
v-show="isCalibrating"
label
:color="useStateStore().calibrationData.hasEnoughImages ? 'secondary' : 'gray'"
>
Snapshots: {{ useStateStore().calibrationData.imageCount }} of at least {{ useStateStore().calibrationData.minimumImageCount }}
</v-chip>
</v-row>
</v-col>
</v-row>
<v-row v-if="isCalibrating">
<v-col
cols="12"
class="pt-0"
>
<cv-slider
v-model="useCameraSettingsStore().currentPipelineSettings.cameraExposure"
:disabled="useCameraSettingsStore().currentCameraSettings.pipelineSettings.cameraAutoExposure"
label="Exposure"
tooltip="Directly controls how much light is allowed to fall onto the sensor, which affects apparent brightness"
:min="0"
:max="100"
:slider-cols="8"
:step="0.1"
@input="args => useCameraSettingsStore().changeCurrentPipelineSetting({cameraExposure: args}, false)"
/>
<cv-slider
v-model="useCameraSettingsStore().currentPipelineSettings.cameraBrightness"
label="Brightness"
:min="0"
:max="100"
:slider-cols="8"
@input="args => useCameraSettingsStore().changeCurrentPipelineSetting({cameraBrightness: args}, false)"
/>
<cv-switch
v-model="useCameraSettingsStore().currentPipelineSettings.cameraAutoExposure"
class="pt-2"
label="Auto Exposure"
:label-cols="4"
tooltip="Enables or Disables camera automatic adjustment for current lighting conditions"
@input="args => useCameraSettingsStore().changeCurrentPipelineSetting({cameraAutoExposure: args}, false)"
/>
<cv-slider
v-if="useCameraSettingsStore().currentPipelineSettings.cameraGain >= 0"
v-model="useCameraSettingsStore().currentPipelineSettings.cameraGain"
label="Camera Gain"
tooltip="Controls camera gain, similar to brightness"
:min="0"
:max="100"
@input="args => useCameraSettingsStore().changeCurrentPipelineSetting({cameraGain: args}, false)"
/>
<cv-slider
v-if="useCameraSettingsStore().currentPipelineSettings.cameraRedGain !== -1"
v-model="useCameraSettingsStore().currentPipelineSettings.cameraRedGain"
label="Red AWB Gain"
:min="0"
:max="100"
tooltip="Controls red automatic white balance gain, which affects how the camera captures colors in different conditions"
@input="args => useCameraSettingsStore().changeCurrentPipelineSetting({cameraRedGain: args}, false)"
/>
<cv-slider
v-if="useCameraSettingsStore().currentPipelineSettings.cameraBlueGain !== -1"
v-model="useCameraSettingsStore().currentPipelineSettings.cameraBlueGain"
label="Blue AWB Gain"
:min="0"
:max="100"
tooltip="Controls blue automatic white balance gain, which affects how the camera captures colors in different conditions"
@input="args => useCameraSettingsStore().changeCurrentPipelineSetting({cameraBlueGain: args}, false)"
/>
</v-col>
</v-row>
<v-row>
<v-col :cols="6">
<v-btn
small
color="secondary"
style="width: 100%;"
:disabled="!settingsValid"
@click="isCalibrating ? useCameraSettingsStore().takeCalibrationSnapshot(true) : startCalibration()"
>
{{ isCalibrating ? "Take Snapshot" : "Start Calibration" }}
</v-btn>
</v-col>
<v-col :cols="6">
<v-btn
small
:color="useStateStore().calibrationData.hasEnoughImages ? 'accent' : 'red'"
:class="useStateStore().calibrationData.hasEnoughImages ? 'black--text' : 'white---text'"
style="width: 100%;"
:disabled="!isCalibrating || !settingsValid"
@click="endCalibration"
>
{{ useStateStore().calibrationData.hasEnoughImages ? "Finish Calibration" : "Cancel Calibration" }}
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col :cols="6">
<v-btn
color="accent"
small
outlined
style="width: 100%;"
:disabled="!settingsValid"
@click="downloadCalibBoard"
>
<v-icon left>
mdi-download
</v-icon>
Generate Board
</v-btn>
</v-col>
<v-col :cols="6">
<v-btn
color="secondary"
:disabled="isCalibrating"
small
style="width: 100%;"
@click="openCalibUploadPrompt"
>
<v-icon left>
mdi-upload
</v-icon>
Import From CalibDB
</v-btn>
<input
ref="importCalibrationFromCalibDB"
type="file"
accept=".json"
style="display: none;"
@change="readImportedCalibration"
>
</v-col>
</v-row>
</div>
</v-card>
<v-dialog
v-model="showCalibEndDialog"
width="500px"
:persistent="true"
>
<v-card
color="primary"
dark
>
<v-card-title class="pb-8">
Camera Calibration
</v-card-title>
<div class="ml-3">
<v-col style="text-align: center">
<template v-if="calibCanceled">
<v-icon
color="blue"
size="70"
>
mdi-cancel
</v-icon>
<v-card-text>Camera Calibration has been Canceled, the backend is attempting to cleanly cancel the calibration process.</v-card-text>
</template>
<template v-else-if="isCalibrating">
<v-progress-circular
indeterminate
:size="70"
:width="8"
color="accent"
/>
<v-card-text>Camera is being calibrated. This process may take several minutes...</v-card-text>
</template>
<template v-else-if="calibSuccess">
<v-icon
color="green"
size="70"
>
mdi-check-bold
</v-icon>
<v-card-text>
Camera has been successfully calibrated for {{ getUniqueVideoResolutionStrings().find(v => v.value === useStateStore().calibrationData.videoFormatIndex).name }}!
</v-card-text>
</template>
<template v-else>
<v-icon
color="red"
size="70"
>
mdi-close
</v-icon>
<v-card-text>Camera calibration failed! Make sure that the photos are taken such that the rainbow grid circles align with the corners of the chessboard, and try again. More information is available in the program logs.</v-card-text>
</template>
</v-col>
</div>
<v-card-actions>
<v-spacer />
<v-btn
v-if="!isCalibrating"
color="white"
text
@click="showCalibEndDialog = false"
>
OK
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<style scoped lang="scss">
.v-data-table {
text-align: center;
th, td {
background-color: #006492 !important;
font-size: 1rem !important;
}
tbody :hover td {
background-color: #005281 !important;
}
::-webkit-scrollbar {
width: 0;
height: 0.55em;
border-radius: 5px;
}
::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background-color: #ffd843;
border-radius: 10px;
}
}
</style>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import CvSelect from "@/components/common/cv-select.vue";
import CvNumberInput from "@/components/common/cv-number-input.vue";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { useStateStore } from "@/stores/StateStore";
import { ref } from "vue";
const currentFov = ref(useCameraSettingsStore().currentCameraSettings.fov.value);
const saveCameraSettings = () => {
useCameraSettingsStore().updateCameraSettings({ fov: currentFov.value }, true)
.then((response) => {
useStateStore().showSnackbarMessage({
color: "success",
message: response.data.text || response.data
});
})
.catch(error => {
if(error.response) {
useStateStore().showSnackbarMessage({
color: "error",
message: error.response.data.text || error.response.data
});
} else if(error.request) {
useStateStore().showSnackbarMessage({
color: "error",
message: "Error while trying to process the request! The backend didn't respond."
});
} else {
useStateStore().showSnackbarMessage({
color: "error",
message: "An error occurred while trying to process the request."
});
}
})
.finally(() => {
useCameraSettingsStore().currentCameraSettings.fov.value = currentFov.value;
});
};
</script>
<template>
<v-card
class="mb-3 pr-6 pb-3"
color="primary"
dark
>
<v-card-title>Camera Settings</v-card-title>
<div class="ml-5">
<cv-select
v-model="useStateStore().currentCameraIndex"
label="Camera"
:items="useCameraSettingsStore().cameraNames"
:select-cols="8"
@input="args => {
currentFov = useCameraSettingsStore().cameras[args].fov.value;
useCameraSettingsStore().setCurrentCameraIndex(args);
}"
/>
<cv-number-input
v-model="currentFov"
:tooltip="!useCameraSettingsStore().currentCameraSettings.fov.managedByVendor ? 'Field of view (in degrees) of the camera measured across the diagonal of the frame, in a video mode which covers the whole sensor area.' : 'This setting is managed by a vendor'"
label="Maximum Diagonal FOV"
:disabled="useCameraSettingsStore().currentCameraSettings.fov.managedByVendor"
:label-cols="4"
/>
<br>
<v-btn
style="margin-top:10px"
small
color="secondary"
:disabled="currentFov === useCameraSettingsStore().currentCameraSettings.fov.value"
@click="saveCameraSettings"
>
<v-icon left>
mdi-content-save
</v-icon>
Save Changes
</v-btn>
</div>
</v-card>
</template>

View File

@@ -0,0 +1,172 @@
<script setup lang="ts">
import PhotonCameraStream from "@/components/app/photon-camera-stream.vue";
import { computed } from "vue";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { PipelineType } from "@/types/PipelineTypes";
import { useStateStore } from "@/stores/StateStore";
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
const props = defineProps<{
// TODO fully update v-model usage in custom components on Vue3 update
value: number[]
}>();
const emit = defineEmits<{
(e: "input", value: number[]): void
}>();
const localValue = computed({
get: () => props.value,
set: v => emit("input", v)
});
const driverMode = computed<boolean>({
get: () => useCameraSettingsStore().isDriverMode,
set: v => useCameraSettingsStore().changeCurrentPipelineIndex(v ? -1 : useCameraSettingsStore().currentCameraSettings.lastPipelineIndex || 0, true)
});
const fpsTooLow = computed<boolean>(() => {
const currFPS = useStateStore().pipelineResults?.fps || 0;
const targetFPS = useCameraSettingsStore().currentVideoFormat.fps;
const driverMode = useCameraSettingsStore().isDriverMode;
const gpuAccel = useSettingsStore().general.gpuAcceleration !== undefined;
const isReflective = useCameraSettingsStore().currentPipelineSettings.pipelineType === PipelineType.Reflective;
return (currFPS - targetFPS) < -5 && currFPS !== 0 && !driverMode && gpuAccel && isReflective;
});
</script>
<template>
<v-card
class="mb-3 pr-6 pb-3 pa-4"
color="primary"
dark
>
<v-card-title
class="pb-0 mb-2 pl-4 pt-1"
style="min-height: 50px; justify-content: space-between; align-content: center"
>
<div style="display: flex; flex-wrap: wrap">
<div>
<span
class="mr-4"
style="white-space: nowrap"
>
Cameras
</span>
</div>
<div>
<v-chip
label
:color="fpsTooLow ? 'error' : 'transparent'"
:text-color="fpsTooLow ? '#C7EA46' : '#ff4d00'"
style="font-size: 1rem; padding: 0; margin: 0;"
>
<span class="pr-1">
{{ Math.round(useStateStore().pipelineResults?.fps || 0) }}&nbsp;FPS &ndash; {{ Math.min(Math.round(useStateStore().pipelineResults?.latency || 0), 9999) }} ms latency
</span>
</v-chip>
</div>
</div>
<div>
<v-switch
v-model="driverMode"
:disabled="useCameraSettingsStore().isCalibrationMode"
label="Driver Mode"
style="margin-left: auto;"
color="accent"
class="pt-2"
/>
</div>
</v-card-title>
<div
class="stream-container pb-4"
>
<div class="stream">
<photon-camera-stream
v-show="value.includes(0)"
stream-type="Raw"
style="max-width: 100%"
/>
</div>
<div class="stream">
<photon-camera-stream
v-show="value.includes(1)"
stream-type="Processed"
style="max-width: 100%"
/>
</div>
</div>
<v-divider />
<div class="pt-4">
<p style="color: white;">
Stream Display
</p>
<v-btn-toggle
v-model="localValue"
:multiple="true"
mandatory
dark
class="fill"
style="width: 100%"
>
<v-btn
color="secondary"
class="fill"
:disabled="useCameraSettingsStore().isDriverMode || useCameraSettingsStore().isCalibrationMode"
>
<v-icon>mdi-import</v-icon>
<span>Raw</span>
</v-btn>
<v-btn
color="secondary"
class="fill"
:disabled="useCameraSettingsStore().isDriverMode || useCameraSettingsStore().isCalibrationMode"
>
<v-icon>mdi-export</v-icon>
<span>Processed</span>
</v-btn>
</v-btn-toggle>
</div>
</v-card>
</template>
<style scoped>
.v-btn-toggle.fill {
width: 100%;
height: 100%;
}
.v-btn-toggle.fill > .v-btn {
width: 50%;
height: 100%;
}
th {
width: 80px;
text-align: center;
}
.stream-container {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.stream {
display: flex;
justify-content: center;
width: 100%;
}
@media only screen and (min-width: 512px) and (max-width: 960px) {
.stream-container {
flex-wrap: nowrap;
}
.stream {
width: 50%;
}
}
</style>

View File

@@ -1,18 +1,35 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
iconName: string,
color?: string,
tooltip?: string,
right?: boolean,
hover?: boolean
}>(), {
right: false,
hover: false
});
const hoverClass = props.hover ? "hover" : "";
</script>
<template>
<div>
<v-tooltip
:right="right"
:bottom="!right"
nudge-right="10"
:disabled="tooltip === undefined"
>
<template v-slot:activator="{ on }">
<template #activator="{ on, attrs }">
<v-icon
:class="hoverClass"
:color="color"
@click="handleClick"
v-bind="attrs"
v-on="on"
@click="$emit('click')"
>
{{ text }}
{{ iconName }}
</v-icon>
</template>
<span>{{ tooltip }}</span>
@@ -20,33 +37,6 @@
</div>
</template>
<script>
export default {
name: 'Icon',
// eslint-disable-next-line vue/require-prop-types
props: ['color', 'tooltip', 'text', 'right', 'hover'],
data() {
return {}
},
computed: {
hoverClass: {
get() {
if (this.hover !== undefined) {
return "hover";
}
return "";
}
}
},
methods: {
handleClick() {
this.$emit('click');
}
},
}
</script>
<style scoped>
.hover:hover {
color: white !important;

View File

@@ -1,110 +0,0 @@
<template>
<img
:id="id"
crossOrigin="anonymous"
:style="styleObject"
:src="src"
:alt="alt"
@click="clickHandler"
@error="loadErrHandler"
>
</template>
<script>
export default {
name: "CvImage",
// eslint-disable-next-line vue/require-prop-types
props: ['address', 'scale', 'maxHeight', 'maxHeightMd', 'maxHeightLg', 'maxHeightXl', 'colorPicking', 'id', 'disconnected', 'alt'],
data() {
return {
seed: 1.0,
}
},
computed: {
styleObject: {
get() {
let ret = {
"border-radius": "3px",
"display": "block",
"object-fit": "contain",
"background-size:": "contain",
"object-position": "50% 50%",
"max-width": "100%",
"margin-left": "auto",
"margin-right": "auto",
"max-height": this.maxHeight,
height: `${this.scale}%`,
cursor: (this.colorPicking ? `url(${require("../../assets/icons/eyedropper.svg")}),` : "pointer") + "default",
};
if (this.$vuetify.breakpoint.xl) {
ret["max-height"] = this.maxHeightXl;
} else if (this.$vuetify.breakpoint.lg) {
ret["max-height"] = this.maxHeightLg;
} else if (this.$vuetify.breakpoint.md) {
ret["max-height"] = this.maxHeightMd;
}
return ret;
}
},
src: {
get() {
const port = this.getCurPort();
if(port <= 0){
//Invalid port, keep it spiny
return require("../../../public/loading.svg");
} else {
//Valid port, connect
return this.getSrcURLFromPort(port);
}
},
},
},
mounted() {
this.reload(); // Force reload image on creation
},
methods: {
getCurPort(){
let port = -1;
if(this.disconnected){
//Disconnected, port is unknown.
port = -1;
} else {
//Connected - get the port
if(this.id === 'raw-stream'){
port = this.$store.state.cameraSettings[this.$store.state.currentCameraIndex].inputStreamPort
} else {
port = this.$store.state.cameraSettings[this.$store.state.currentCameraIndex].outputStreamPort
}
}
return port;
},
getSrcURLFromPort(port){
return "http://" + location.hostname + ":" + port + "/stream.mjpg" + "?" + this.seed;
},
loadErrHandler(event) {
console.log(event);
console.log("Error loading image, attempting to do it again...");
this.reload();
},
clickHandler(event) {
if(this.colorPicking){
this.$emit('click', event);
} else {
const port = this.getCurPort();
if(port <= 0){
console.log("No valid port, ignoring click.");
} else {
//Valid port, connect
window.open(this.getSrcURLFromPort(port), '_blank');
}
}
},
reload() {
this.seed = new Date().getTime();
}
},
}
</script>

View File

@@ -1,64 +1,77 @@
<script setup lang="ts">
import { computed } from "vue";
import TooltippedLabel from "@/components/common/cv-tooltipped-label.vue";
const props = withDefaults(defineProps<{
label?: string,
tooltip?: string,
// TODO fully update v-model usage in custom components on Vue3 update
value: string,
disabled?: boolean,
errorMessage?: string,
placeholder?: string,
labelCols?: number,
inputCols?: number,
rules?: ((v: string) => boolean | string)[]
}>(), {
disabled: false,
inputCols: 8
});
const emit = defineEmits<{
(e: "input", value: string): void
(e: "onEnter", value: string): void
(e: "onEscape"): void
}>();
const localValue = computed({
get: () => props.value,
set: v => emit("input", v)
});
const handleKeydown = ({ key }) => {
switch (key) {
case "Enter":
if(!(props.rules || []).some(v => v(localValue.value) === false || typeof v(localValue.value) === "string")) {
emit("onEnter", localValue.value);
}
break;
case "Escape":
emit("onEscape");
break;
}
};
</script>
<template>
<div>
<v-row
dense
align="center"
>
<v-col :cols="labelCols || (12 - (inputCols || 8))">
<v-col :cols="labelCols || (12 - inputCols)">
<tooltipped-label
:tooltip="tooltip"
:text="name"
:label="label"
/>
</v-col>
<v-col :cols="inputCols || 8">
<v-col :cols="inputCols">
<v-text-field
v-model="localValue"
dark
dense
color="accent"
:placeholder="placeholder"
:disabled="disabled"
:error-messages="errorMessage"
:rules="rules"
class="mt-1 pt-2"
@keydown="handleKeyboard"
@keydown="handleKeydown"
/>
</v-col>
</v-row>
</div>
</template>
<script>
import TooltippedLabel from "./cv-tooltipped-label";
export default {
name: 'Input',
components: {
TooltippedLabel
},
// eslint-disable-next-line vue/require-prop-types
props: ['name', 'value', 'disabled', 'errorMessage', 'inputCols', 'labelCols', 'rules', 'tooltip'],
data() {
return {}
},
computed: {
localValue: {
get() {
return this.value;
},
set(value) {
this.$emit('input', value);
}
}
},
methods: {
handleKeyboard(event) {
if (event.key === "Enter") {
this.$emit("Enter");
}
}
}
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -1,13 +1,42 @@
<script setup lang="ts">
import TooltippedLabel from "@/components/common/cv-tooltipped-label.vue";
import { computed } from "vue";
const props = withDefaults(defineProps<{
label?: string,
tooltip?: string,
// TODO fully update v-model usage in custom components on Vue3 update
value: number,
disabled?: boolean,
labelCols?: number,
rules?: ((v: number) => boolean | string)[],
step?: number
}>(), {
disabled: false,
labelCols: 2,
step: 1
});
const emit = defineEmits<{
(e: "input", value: number): void
}>();
const localValue = computed({
get: () => props.value,
set: v => emit("input", parseFloat(v as unknown as string))
});
</script>
<template>
<div>
<v-row
dense
align="center"
>
<v-col :cols="labelCols || 2">
<v-col :cols="labelCols">
<tooltipped-label
:tooltip="tooltip"
:text="name"
:label="label"
/>
</v-col>
<v-col>
@@ -28,30 +57,3 @@
</v-row>
</div>
</template>
<script>
import TooltippedLabel from "./cv-tooltipped-label";
export default {
name: 'NumberInput',
components: {
TooltippedLabel,
},
// eslint-disable-next-line vue/require-prop-types
props: ['name', 'value', 'step', 'labelCols', 'rules', 'tooltip', 'disabled'],
computed: {
localValue: {
get() {
return this.value;
},
set(value) {
this.$emit('input', parseFloat(value));
}
}
}
}
</script>
<style lang="" scoped>
</style>

View File

@@ -1,16 +1,43 @@
<script setup lang="ts">
import { computed } from "vue";
import TooltippedLabel from "@/components/common/cv-tooltipped-label.vue";
const props = withDefaults(defineProps<{
label?: string,
tooltip?: string,
// TODO fully update v-model usage in custom components on Vue3 update
value: number,
disabled?: boolean,
inputCols?: number,
list: string[]
}>(), {
disabled: false,
inputCols: 8
});
const emit = defineEmits<{
(e: "input", value: number): void
}>();
const localValue = computed({
get: () => props.value,
set: v => emit("input", v)
});
</script>
<template>
<div>
<v-row
dense
align="center"
>
<v-col :cols="12 - (inputCols || 8)">
<v-col :cols="12 - inputCols">
<tooltipped-label
:tooltip="tooltip"
:text="name"
:label="label"
/>
</v-col>
<v-col :cols="inputCols || 8">
<v-col :cols="inputCols">
<v-radio-group
v-model="localValue"
row
@@ -18,7 +45,7 @@
:mandatory="true"
>
<v-radio
v-for="(radioName,index) in list"
v-for="(radioName, index) in list"
:key="index"
color="#ffd843"
:label="radioName"
@@ -30,33 +57,3 @@
</v-row>
</div>
</template>
<script>
import TooltippedLabel from "./cv-tooltipped-label";
export default {
name: 'Radio',
components: {
TooltippedLabel
},
// eslint-disable-next-line vue/require-prop-types
props: ['name', 'value', 'list', 'disabled', 'inputCols', 'tooltip'],
data() {
return {}
},
computed: {
localValue: {
get() {
return this.value;
},
set(value) {
this.$emit('input', value);
}
}
}
}
</script>
<style lang="" scoped>
</style>

View File

@@ -1,18 +1,60 @@
<script setup lang="ts">
import { computed } from "vue";
import TooltippedLabel from "@/components/common/cv-tooltipped-label.vue";
const props = withDefaults(defineProps<{
label?: string,
tooltip?: string,
// TODO fully update v-model usage in custom components on Vue3 update
// value: [number, number] | WebsocketNumberPair, // Vue doesnt like Union types for the value prop for some reason.
value: [number, number],
min: number,
max: number,
step?: number,
sliderCols?: number,
disabled?: boolean,
inverted?: boolean,
}>(), {
step: 1,
disabled: false,
inverted: false,
sliderCols: 10
});
const emit = defineEmits<{
(e: "input", value: [number, number]): void
}>();
const localValue = computed<[number, number]>({
get: ():[number, number] => {
return Object.values(props.value) as [number, number];
},
set: v => emit("input", v)
});
const changeFromSlot = (v: number, i: number) => {
// localValue.value must be replaced for a reactive change to take place
const temp = localValue.value;
temp[i] = v;
localValue.value = temp;
};
</script>
<template>
<div>
<v-row
dense
align="center"
>
<v-col cols="2">
<v-col :cols="12 - sliderCols">
<tooltipped-label
:tooltip="tooltip"
:text="name"
:label="label"
/>
</v-col>
<v-col cols="10">
<v-col :cols="sliderCols">
<v-range-slider
:value="localValue"
v-model="localValue"
:max="max"
:min="min"
:disabled="disabled"
@@ -23,44 +65,37 @@
:track-color="inverted ? 'accent' : undefined"
thumb-color="accent"
:step="step"
@input="handleInput"
@mousedown="$emit('rollback', localValue)"
>
<template v-slot:prepend>
<template #prepend>
<v-text-field
:value="localValue[0]"
dark
color="accent"
:value="localValue[0]"
:max="max"
:min="min"
class="mt-0 pt-0"
hide-details
single-line
:max="max"
:min="min"
:step="step"
type="number"
style="width: 60px"
:step="step"
@input="handleChange"
@focus="prependFocused = true"
@blur="prependFocused = false"
@input="v => changeFromSlot(v, 0)"
/>
</template>
<template v-slot:append>
<template #append>
<v-text-field
:value="localValue[1]"
dark
color="accent"
:value="localValue[1]"
:max="max"
:min="min"
class="mt-0 pt-0"
hide-details
single-line
:max="max"
:min="min"
:step="step"
type="number"
style="width: 60px"
:step="step"
@input="handleChange"
@focus="appendFocused = true"
@blur="appendFocused = false"
@input="v => changeFromSlot(v, 1)"
/>
</template>
</v-range-slider>
@@ -68,67 +103,3 @@
</v-row>
</div>
</template>
<script>
import TooltippedLabel from "./cv-tooltipped-label";
export default {
name: "RangeSlider",
components: {
TooltippedLabel,
},
// eslint-disable-next-line vue/require-prop-types
props: ["name", "min", "max", "value", "step", "tooltip", "disabled", "inverted"],
data() {
return {
prependFocused: false,
appendFocused: false,
currentTempVal: null,
};
},
computed: {
localValue: {
get() {
return Object.values(this.value || [0, 0]);
},
set(value) {
this.$emit("input", value);
},
},
},
methods: {
delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
},
async handleChange(val) {
this.currentTempVal = val;
await this.delay(200).then(() => {
let i = 0;
if (!this.prependFocused && this.appendFocused) {
i = 1;
}
// will get empty string if entry is not a number
if (this.currentTempVal !== val || val === "") return;
let parsed = parseFloat(val);
let tmp = this.localValue;
tmp[i] = Math.max(this.min, Math.min(parsed, this.max));
this.localValue = tmp;
this.$emit("rollback", this.localValue);
});
},
handleInput(val) {
if (!this.prependFocused || !this.appendFocused) {
this.localValue = val;
}
},
},
};
</script>
<style lang="" scoped>
</style>

View File

@@ -1,66 +1,70 @@
<script setup lang="ts">
import { computed } from "vue";
import TooltippedLabel from "@/components/common/cv-tooltipped-label.vue";
interface SelectItem {
name: string | number,
value: string | number,
disabled?: boolean
}
const props = withDefaults(defineProps<{
label?: string,
tooltip?: string,
selectCols?: number,
// TODO fully update v-model usage in custom components on Vue3 update
value: number,
disabled?: boolean,
items: string[] | number[] | SelectItem[]
}>(), {
selectCols: 9,
disabled: false
});
const emit = defineEmits<{
(e: "input", value: number): void
}>();
const localValue = computed({
get: () => props.value,
set: v => emit("input", v)
});
// Computed in case items changes
const items = computed<SelectItem[]>(() => {
// Check if the prop exists on the object to infer object type
if((props.items[0] as SelectItem).name) {
return props.items as SelectItem[];
}
return props.items.map((v, i) => ({ name: v, value: i }));
});
</script>
<template>
<div>
<v-row
dense
align="center"
>
<v-col :cols="12 - (selectCols || 9)">
<v-col :cols="12 - selectCols">
<tooltipped-label
:tooltip="tooltip"
:text="name"
:label="label"
/>
</v-col>
<v-col :cols="selectCols || 9">
<v-col :cols="selectCols">
<v-select
v-model="localValue"
:items="indexList"
:items="items"
item-text="name"
item-value="index"
item-value="value"
item-disabled="disabled"
dark
color="accent"
item-color="secondary"
:disabled="disabled"
:rules="rules"
@change="$emit('rollback', localValue)"
/>
</v-col>
</v-row>
</div>
</template>
<script>
import TooltippedLabel from "./cv-tooltipped-label";
export default {
name: 'Select',
components: {
TooltippedLabel,
},
// eslint-disable-next-line vue/require-prop-types
props: ['list', 'name', 'value', 'disabled', 'filteredIndices', 'selectCols', 'rules', 'tooltip'],
computed: {
localValue: {
get() {
return this.value;
},
set(value) {
this.$emit('input', value)
}
},
indexList() {
let list = [];
for (let i = 0; i < this.list.length; i++) {
if (this.filteredIndices instanceof Set && this.filteredIndices.has(i)) continue;
list.push({
name: this.list[i],
index: i
});
}
return list;
}
}
}
</script>
<style>
</style>

View File

@@ -1,18 +1,48 @@
<script setup lang="ts">
import { computed } from "vue";
import TooltippedLabel from "@/components/common/cv-tooltipped-label.vue";
const props = withDefaults(defineProps<{
label?: string,
tooltip?: string,
// TODO fully update v-model usage in custom components on Vue3 update
value: number,
min: number,
max: number,
step?: number
disabled?: boolean,
sliderCols?: number,
}>(), {
step: 1,
disabled: false,
sliderCols: 8
});
const emit = defineEmits<{
(e: "input", value: number): void
}>();
const localValue = computed({
get: () => props.value,
set: v => emit("input", v)
});
</script>
<template>
<div>
<v-row
dense
align="center"
>
<v-col :cols="12 - (sliderCols || 8)">
<v-col :cols="12 - sliderCols">
<tooltipped-label
:tooltip="tooltip"
:text="name"
:label="label"
/>
</v-col>
<v-col :cols="sliderCols || 8">
<v-col :cols="sliderCols">
<v-slider
:value="localValue"
v-model="localValue"
dark
class="align-center"
:max="max"
@@ -21,29 +51,21 @@
color="accent"
:disabled="disabled"
:step="step"
@start="isClicked = true"
@end="isClicked = false"
@change="handleClick"
@input="handleInput"
@mousedown="$emit('rollback', localValue)"
>
<template v-slot:append>
<template #append>
<v-text-field
v-model="localValue"
dark
color="accent"
:max="max"
:min="min"
:disabled="disabled"
:value="localValue"
class="mt-0 pt-0"
hide-details
single-line
type="number"
style="width: 50px"
:step="step"
@input="handleChange"
@focus="isFocused = true"
@blur="isFocused = false"
/>
</template>
</v-slider>
@@ -51,58 +73,3 @@
</v-row>
</div>
</template>
<script>
import TooltippedLabel from "./cv-tooltipped-label";
export default {
name: "Slider",
components: {
TooltippedLabel,
},
// eslint-disable-next-line vue/require-prop-types
props: ["min", "max", "name", "value", "step", "sliderCols", "disabled", "tooltip"],
data() {
return {
isFocused: false,
isClicked: false,
currentBoxVal: null
};
},
computed: {
localValue: {
get() {
return this.value;
},
set(value) {
this.$emit("input", value);
}
},
},
methods: {
handleChange(val) {
this.currentBoxVal = val;
setTimeout(() => {
if (this.currentBoxVal !== val) return;
// if (this.isFocused) {
this.localValue = parseFloat(val);
this.$emit("rollback", this.localValue);
// }
}, 200);
},
handleInput(val) {
if (!this.isFocused && this.isClicked) {
this.localValue = val;
}
},
handleClick(val) {
if (!this.isFocused) {
this.localValue = val;
}
}
}
};
</script>
<style lang="" scoped>
</style>

View File

@@ -1,51 +1,50 @@
<script setup lang="ts">
import TooltippedLabel from "@/components/common/cv-tooltipped-label.vue";
import { computed } from "vue";
const props = withDefaults(defineProps<{
label?: string,
tooltip?: string,
// TODO fully update v-model usage in custom components on Vue3 update
value: boolean,
disabled?: boolean,
labelCols?: number,
switchCols?: number
}>(), {
disabled: false,
labelCols: 2
});
const emit = defineEmits<{
(e: "input", value: boolean): void
}>();
const localValue = computed({
get: () => props.value,
set: v => emit("input", v)
});
</script>
<template>
<div>
<v-row
dense
align="center"
>
<v-col :cols="textCols || 2">
<v-col :cols="(12 - switchCols) || labelCols">
<tooltipped-label
:tooltip="tooltip"
:text="name"
:label="label"
/>
</v-col>
<v-col :cols="12 - (textCols || 2)">
<v-col :cols="switchCols || (12 - labelCols)">
<v-switch
v-model="localValue"
dark
:disabled="disabled"
color="#ffd843"
@change="$emit('rollback', localValue)"
/>
</v-col>
</v-row>
</div>
</template>
<script>
import TooltippedLabel from "./cv-tooltipped-label";
export default {
name: 'CVSwitch',
components: {
TooltippedLabel,
},
// eslint-disable-next-line vue/require-prop-types
props: ['name', 'value', 'disabled', 'textCols', 'tooltip'],
computed: {
localValue: {
get() {
return this.value;
},
set(value) {
this.$emit('input', value)
}
}
}
}
</script>
<style lang="" scoped>
</style>

View File

@@ -1,3 +1,10 @@
<script setup lang="ts">
defineProps<{
label?: string,
tooltip?: string
}>();
</script>
<template>
<div>
<v-tooltip
@@ -5,23 +12,15 @@
right
open-delay="300"
>
<template v-slot:activator="{ on, attrs }">
<template #activator="{ on, attrs }">
<span
style="cursor: text !important;"
class="white--text"
v-bind="attrs"
v-on="on"
>{{ text }}</span>
>{{ label }}</span>
</template>
<span>{{ tooltip }}</span>
</v-tooltip>
</div>
</template>
<script>
export default {
name: 'TooltippedLabel',
// eslint-disable-next-line vue/require-prop-types
props: ['text', 'tooltip'],
}
</script>

View File

@@ -0,0 +1,481 @@
<script setup lang="ts">
import CvSelect from "@/components/common/cv-select.vue";
import { useStateStore } from "@/stores/StateStore";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { WebsocketPipelineType } from "@/types/WebsocketDataTypes";
import { computed, ref } from "vue";
import CvIcon from "@/components/common/cv-icon.vue";
import CvInput from "@/components/common/cv-input.vue";
import { PipelineType } from "@/types/PipelineTypes";
const changeCurrentCameraIndex = (index: number) => {
useCameraSettingsStore().setCurrentCameraIndex(index, true);
switch (useCameraSettingsStore().cameras[index].pipelineSettings.pipelineType) {
case PipelineType.Reflective:
pipelineType.value = WebsocketPipelineType.Reflective;
break;
case PipelineType.ColoredShape:
pipelineType.value = WebsocketPipelineType.ColoredShape;
break;
case PipelineType.AprilTag:
pipelineType.value = WebsocketPipelineType.AprilTag;
break;
case PipelineType.Aruco:
pipelineType.value = WebsocketPipelineType.Aruco;
break;
}
};
// Common RegEx used for naming both pipelines and cameras
const nameChangeRegex = /^[A-Za-z0-9_ \-)(]*[A-Za-z0-9][A-Za-z0-9_ \-)(.]*$/;
// Camera Name Edit
const isCameraNameEdit = ref(false);
const currentCameraName = ref(useCameraSettingsStore().currentCameraSettings.nickname);
const startCameraNameEdit = () => {
currentCameraName.value = useCameraSettingsStore().currentCameraSettings.nickname;
isCameraNameEdit.value = true;
};
const checkCameraName = (name: string): string | boolean => {
if(!nameChangeRegex.test(name)) return "A camera name can only contain letters, numbers, spaces, underscores, hyphens, parenthesis, and periods";
if(useCameraSettingsStore().cameraNames.some(cameraName => cameraName === name)) return "This camera name has already been used";
return true;
};
const saveCameraNameEdit = (newName: string) => {
useCameraSettingsStore().changeCameraNickname(newName, false)
.then(response => {
useStateStore().showSnackbarMessage({
color: "success",
message: response.data.text || response.data
});
useCameraSettingsStore().currentCameraSettings.nickname = newName;
})
.catch(error => {
if(error.response) {
useStateStore().showSnackbarMessage({
color: "error",
message: error.response.data.text || error.response.data
});
} else if(error.request) {
useStateStore().showSnackbarMessage({
color: "error",
message: "Error while trying to process the request! The backend didn't respond."
});
} else {
useStateStore().showSnackbarMessage({
color: "error",
message: "An error occurred while trying to process the request."
});
}
currentCameraName.value = useCameraSettingsStore().currentCameraSettings.nickname;
})
.finally(() => isCameraNameEdit.value = false);
};
const cancelCameraNameEdit = () => {
isCameraNameEdit.value = false;
currentCameraName.value = useCameraSettingsStore().currentCameraSettings.nickname;
};
// Pipeline Name Edit
const pipelineNamesWrapper = computed<{name: string, value: number}[]>(() => {
const pipelineNames = useCameraSettingsStore().pipelineNames.map((name, index) => ({ name: name, value: index }));
if(useCameraSettingsStore().isDriverMode) {
pipelineNames.push({ name: "Driver Mode", value: WebsocketPipelineType.DriverMode });
}
if(useCameraSettingsStore().isCalibrationMode) {
pipelineNames.push({ name: "3D Calibration Mode", value: WebsocketPipelineType.Calib3d });
}
return pipelineNames;
});
const isPipelineNameEdit = ref(false);
const currentPipelineName = ref(useCameraSettingsStore().currentPipelineSettings.pipelineNickname);
const startPipelineNameEdit = () => {
currentPipelineName.value = useCameraSettingsStore().currentPipelineSettings.pipelineNickname;
isPipelineNameEdit.value = true;
};
const checkPipelineName = (name: string): string | boolean => {
if(!nameChangeRegex.test(name)) return "A pipeline name can only contain letters, numbers, spaces, underscores, hyphens, parenthesis, and periods";
if(useCameraSettingsStore().pipelineNames.some(pipelineName => pipelineName === name)) return "This pipeline name has already been used";
return true;
};
const savePipelineNameEdit = (name: string) => {
useCameraSettingsStore().changeCurrentPipelineNickname(name);
isPipelineNameEdit.value = false;
};
const cancelPipelineNameEdit = () => {
isPipelineNameEdit.value = false;
currentPipelineName.value = useCameraSettingsStore().currentPipelineSettings.pipelineNickname;
};
// Pipeline Creation
const showPipelineCreationDialog = ref(false);
const newPipelineName = ref("");
const newPipelineType = ref<WebsocketPipelineType>(useCameraSettingsStore().currentWebsocketPipelineType);
const showCreatePipelineDialog = () => {
newPipelineName.value = "";
newPipelineType.value = useCameraSettingsStore().currentWebsocketPipelineType;
showPipelineCreationDialog.value = true;
};
const createNewPipeline = () => {
const type = newPipelineType.value;
if(type === WebsocketPipelineType.DriverMode || type === WebsocketPipelineType.Calib3d) return;
useCameraSettingsStore().createNewPipeline(newPipelineName.value, type);
showPipelineCreationDialog.value = false;
};
const cancelPipelineCreation = () => {
showPipelineCreationDialog.value = false;
newPipelineName.value = "";
newPipelineType.value = useCameraSettingsStore().currentWebsocketPipelineType;
};
// Pipeline Creation
const showPipelineDeletionConfirmationDialog = ref(false);
const confirmDeleteCurrentPipeline = () => {
useCameraSettingsStore().deleteCurrentPipeline();
showPipelineDeletionConfirmationDialog.value = false;
};
// Pipeline Type Change
const showPipelineTypeChangeDialog = ref(false);
const pipelineTypesWrapper = computed<{name: string, value: number}[]>(() => {
const pipelineTypes =[
{ name: "Reflective", value: WebsocketPipelineType.Reflective },
{ name: "Colored Shape", value: WebsocketPipelineType.ColoredShape },
{ name: "AprilTag", value: WebsocketPipelineType.AprilTag }
// { name: "Aruco", value: WebsocketPipelineType.Aruco }
];
if(useCameraSettingsStore().isDriverMode) {
pipelineTypes.push({ name: "Driver Mode", value: WebsocketPipelineType.DriverMode });
}
if(useCameraSettingsStore().isCalibrationMode) {
pipelineTypes.push({ name: "3D Calibration Mode", value: WebsocketPipelineType.Calib3d });
}
return pipelineTypes;
});
const pipelineType = ref<WebsocketPipelineType>(useCameraSettingsStore().currentWebsocketPipelineType);
const currentPipelineType = computed<WebsocketPipelineType>({
get: () => {
if(useCameraSettingsStore().isDriverMode) return WebsocketPipelineType.DriverMode;
if(useCameraSettingsStore().isCalibrationMode) return WebsocketPipelineType.Calib3d;
return pipelineType.value;
},
set: v => {
pipelineType.value = v;
}
});
const confirmChangePipelineType = () => {
const type = currentPipelineType.value;
if(type === WebsocketPipelineType.DriverMode || type === WebsocketPipelineType.Calib3d) return;
useCameraSettingsStore().changeCurrentPipelineType(type);
showPipelineTypeChangeDialog.value = false;
};
const cancelChangePipelineType = () => {
pipelineType.value = useCameraSettingsStore().currentWebsocketPipelineType;
showPipelineTypeChangeDialog.value = false;
};
// Change Props whenever the pipeline settings are changed
useCameraSettingsStore().$subscribe((mutation, state) => {
const currentCameraSettings = state.cameras[useStateStore().currentCameraIndex];
switch (currentCameraSettings.pipelineSettings.pipelineType) {
case PipelineType.Reflective:
pipelineType.value = WebsocketPipelineType.Reflective;
break;
case PipelineType.ColoredShape:
pipelineType.value = WebsocketPipelineType.ColoredShape;
break;
case PipelineType.AprilTag:
pipelineType.value = WebsocketPipelineType.AprilTag;
break;
case PipelineType.Aruco:
pipelineType.value = WebsocketPipelineType.Aruco;
break;
}
});
</script>
<template>
<v-card
color="primary"
>
<v-row style="padding: 12px 12px 0 24px">
<v-col
cols="10"
class="pa-0"
>
<cv-select
v-if="!isCameraNameEdit"
v-model="useStateStore().currentCameraIndex"
label="Camera"
:items="useCameraSettingsStore().cameraNames"
@input="changeCurrentCameraIndex"
/>
<cv-input
v-else
v-model="currentCameraName"
class="pt-2"
:input-cols="12-3"
:rules="[v => checkCameraName(v)]"
label="Camera"
@onEnter="saveCameraNameEdit"
@onEscape="cancelCameraNameEdit"
/>
</v-col>
<v-col
cols="2"
style="display: flex; align-items: center; justify-content: center"
>
<cv-icon
color="#c5c5c5"
icon-name="mdi-pencil"
tooltip="Edit Camera Name"
@click="startCameraNameEdit"
/>
</v-col>
</v-row>
<v-row style="padding: 0 12px 0 24px;">
<v-col
cols="10"
class="pa-0"
>
<cv-select
v-if="!isPipelineNameEdit"
:value="useCameraSettingsStore().currentCameraSettings.currentPipelineIndex"
label="Pipeline"
tooltip="Each pipeline runs on a camera output and stores a unique set of processing settings"
:disabled="useCameraSettingsStore().isDriverMode
|| useCameraSettingsStore().isCalibrationMode"
:items="pipelineNamesWrapper"
@input="args => useCameraSettingsStore().changeCurrentPipelineIndex(args, true)"
/>
<cv-input
v-else
v-model="currentPipelineName"
:input-cols="12-3"
:rules="[v => checkPipelineName(v)]"
label="Pipeline"
@onEnter="v => savePipelineNameEdit(v)"
@onEscape="cancelPipelineNameEdit"
/>
</v-col>
<v-col
cols="2"
class="pa-0"
style="display: flex; align-items: center; justify-content: center"
>
<v-menu
v-if="!useCameraSettingsStore().isDriverMode"
offset-y
nudge-bottom="7"
auto
>
<template #activator="{ on }">
<v-icon
color="#c5c5c5"
v-on="on"
@click="cancelPipelineNameEdit"
>
mdi-menu
</v-icon>
</template>
<v-list
dark
dense
color="primary"
>
<v-list-item @click="startPipelineNameEdit">
<v-list-item-title>
<cv-icon
color="#c5c5c5"
:right="true"
icon-name="mdi-pencil"
tooltip="Edit pipeline name"
/>
</v-list-item-title>
</v-list-item>
<v-list-item @click="showCreatePipelineDialog">
<v-list-item-title>
<cv-icon
color="#c5c5c5"
:right="true"
icon-name="mdi-plus"
tooltip="Add new pipeline"
/>
</v-list-item-title>
</v-list-item>
<v-list-item @click="showPipelineDeletionConfirmationDialog = true">
<v-list-item-title>
<cv-icon
color="red darken-2"
:right="true"
icon-name="mdi-delete"
tooltip="Delete pipeline"
/>
</v-list-item-title>
</v-list-item>
<v-list-item @click="useCameraSettingsStore().duplicatePipeline(useCameraSettingsStore().currentCameraSettings.currentPipelineIndex)">
<v-list-item-title>
<cv-icon
color="#c5c5c5"
:right="true"
icon-name="mdi-content-copy"
tooltip="Duplicate pipeline"
/>
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-col>
</v-row>
<v-row style="padding: 0 12px 12px 24px;">
<v-col
cols="10"
class="pa-0"
>
<cv-select
v-model="currentPipelineType"
label="Type"
tooltip="Changes the pipeline type, which changes the type of processing that will happen on input frames"
:disabled="useCameraSettingsStore().isDriverMode
|| useCameraSettingsStore().isCalibrationMode"
:items="pipelineTypesWrapper"
@input="showPipelineTypeChangeDialog = true"
/>
</v-col>
</v-row>
<v-dialog
v-model="showPipelineCreationDialog"
dark
persistent
width="500"
>
<v-card
dark
color="primary"
>
<v-card-title
class="headline"
style="font-family: 'Prompt', sans-serif !important;"
primary-title
>
Create New Pipeline
</v-card-title>
<v-card-text>
<cv-input
v-model="newPipelineName"
placeholder="Pipeline Name"
:label-cols="3"
:input-cols="12-3"
label="Pipeline Name"
:rules="[v => checkPipelineName(v)]"
/>
<cv-select
v-model="newPipelineType"
:select-cols="12-3"
label="Tracking Type"
tooltip="Pipeline type, which changes the type of processing that will happen on input frames"
:items="[
{ name: 'Reflective', value: WebsocketPipelineType.Reflective },
{ name: 'Colored Shape', value: WebsocketPipelineType.ColoredShape },
{ name: 'AprilTag', value: WebsocketPipelineType.AprilTag }
// { name: 'Aruco', value: WebsocketPipelineType.Aruco }
]"
/>
</v-card-text>
<v-divider />
<v-card-actions>
<v-spacer />
<v-btn
color="#ffd843"
:disabled="checkPipelineName(newPipelineName) !== true"
@click="createNewPipeline"
>
Save
</v-btn>
<v-btn
color="error"
@click="cancelPipelineCreation"
>
Cancel
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog
v-model="showPipelineDeletionConfirmationDialog"
dark
width="500"
>
<v-card
dark
color="primary"
>
<v-card-title
class="headline"
style="font-family: 'Prompt', sans-serif !important;"
primary-title
>
Pipeline Deletion Confirmation
</v-card-title>
<v-card-text>
Are you sure you want to delete this pipeline? This cannot be undone.
</v-card-text>
<v-divider />
<v-card-actions>
<v-spacer />
<v-btn
color="error"
@click="confirmDeleteCurrentPipeline"
>
Yes, I'm sure
</v-btn>
<v-btn
color="#ffd843"
@click="showPipelineDeletionConfirmationDialog = false"
>
No, take me back
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog
v-model="showPipelineTypeChangeDialog"
persistent
width="600"
>
<v-card
color="primary"
dark
>
<v-card-title>Change Pipeline Type</v-card-title>
<v-card-text>
Are you sure you want to change the current pipeline type? This will cause all the pipeline settings to be overwritten and they will be lost. If this isn't what you want, duplicate this pipeline first or export settings.
</v-card-text>
<v-divider />
<v-card-actions>
<v-spacer />
<v-btn
color="error"
@click="confirmChangePipelineType"
>
Yes, I'm sure
</v-btn>
<v-btn
color="#ffd843"
@click="cancelChangePipelineType"
>
No, take me back
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import { computed } from "vue";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { useStateStore } from "@/stores/StateStore";
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
import { PipelineType } from "@/types/PipelineTypes";
import PhotonCameraStream from "@/components/app/photon-camera-stream.vue";
defineProps<{
// TODO fully update v-model usage in custom components on Vue3 update
value: number[]
}>();
const driverMode = computed<boolean>({
get: () => useCameraSettingsStore().isDriverMode,
set: v => useCameraSettingsStore().changeCurrentPipelineIndex(v ? -1 : useCameraSettingsStore().currentCameraSettings.lastPipelineIndex || 0, true)
});
const fpsTooLow = computed<boolean>(() => {
const currFPS = useStateStore().pipelineResults?.fps || 0;
const targetFPS = useCameraSettingsStore().currentVideoFormat.fps;
const driverMode = useCameraSettingsStore().isDriverMode;
const gpuAccel = useSettingsStore().general.gpuAcceleration !== undefined;
const isReflective = useCameraSettingsStore().currentPipelineSettings.pipelineType === PipelineType.Reflective;
return (currFPS - targetFPS) < -5 && currFPS !== 0 && !driverMode && gpuAccel && isReflective;
});
</script>
<template>
<v-card
color="primary"
height="100%"
style="display: flex; flex-direction: column"
dark
>
<v-card-title
class="pb-0 mb-0 pl-4 pt-1"
style="min-height: 50px; justify-content: space-between; align-content: center"
>
<div class="pt-2">
<span class="mr-4">Cameras</span>
<v-chip
label
:color="fpsTooLow ? 'error' : 'transparent'"
:text-color="fpsTooLow ? '#C7EA46' : '#ff4d00'"
style="font-size: 1rem; padding: 0; margin: 0"
>
<span class="pr-1">
Processing @ {{ Math.round(useStateStore().pipelineResults?.fps || 0) }}&nbsp;FPS &ndash;
</span>
<span v-if="fpsTooLow && !useCameraSettingsStore().currentPipelineSettings.inputShouldShow && useCameraSettingsStore().currentPipelineSettings.pipelineType === PipelineType.Reflective">
HSV thresholds are too broad; narrow them for better performance
</span>
<span v-else-if="fpsTooLow && useCameraSettingsStore().currentPipelineSettings.inputShouldShow">
stop viewing the raw stream for better performance
</span>
<span v-else>
{{ Math.min(Math.round(useStateStore().pipelineResults?.latency || 0), 9999) }} ms latency
</span>
</v-chip>
</div>
<div>
<v-switch
v-model="driverMode"
label="Driver Mode"
style="margin-left: auto;"
color="accent"
class="pt-2"
/>
</div>
</v-card-title>
<v-divider style="border-color: white" />
<v-row
class="pl-3 pr-3 pt-3 pb-3"
style="flex-wrap: nowrap; justify-content: center"
>
<v-col
v-show="value.includes(0)"
style="max-width: 500px; display: flex; align-items: center"
>
<photon-camera-stream
id="input-camera-stream"
stream-type="Raw"
style="width: 100%; height: auto"
/>
</v-col>
<v-col
v-show="value.includes(1)"
style="max-width: 500px; display: flex; align-items: center"
>
<photon-camera-stream
id="output-camera-stream"
stream-type="Processed"
style="width: 100%; height: auto"
/>
</v-col>
</v-row>
</v-card>
</template>

View File

@@ -0,0 +1,162 @@
<script setup lang="ts">
import type { Component } from "vue";
import { computed, getCurrentInstance, onBeforeUpdate, ref } from "vue";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { useStateStore } from "@/stores/StateStore";
import InputTab from "@/components/dashboard/tabs/InputTab.vue";
import ThresholdTab from "@/components/dashboard/tabs/ThresholdTab.vue";
import ContoursTab from "@/components/dashboard/tabs/ContoursTab.vue";
import AprilTagTab from "@/components/dashboard/tabs/AprilTagTab.vue";
import ArucoTab from "@/components/dashboard/tabs/ArucoTab.vue";
import OutputTab from "@/components/dashboard/tabs/OutputTab.vue";
import TargetsTab from "@/components/dashboard/tabs/TargetsTab.vue";
import PnPTab from "@/components/dashboard/tabs/PnPTab.vue";
import Map3DTab from "@/components/dashboard/tabs/Map3DTab.vue";
import { WebsocketPipelineType } from "@/types/WebsocketDataTypes";
interface ConfigOption {
tabName: string,
component: Component
}
const allTabs = Object.freeze({
inputTab: {
tabName: "Input",
component: InputTab
},
thresholdTab: {
tabName: "Threshold",
component: ThresholdTab
},
contoursTab: {
tabName: "Contours",
component: ContoursTab
},
apriltagTab: {
tabName: "AprilTag",
component: AprilTagTab
},
arucoTab: {
tabName: "Aruco",
component: ArucoTab
},
outputTab: {
tabName: "Output",
component: OutputTab
},
targetsTab: {
tabName: "Targets",
component: TargetsTab
},
pnpTab: {
tabName: "PnP",
component: PnPTab
},
map3dTab: {
tabName: "3D",
component: Map3DTab
}
});
const selectedTabs = ref([0, 0, 0, 0]);
const getTabGroups = (): ConfigOption[][] => {
const smAndDown = getCurrentInstance()?.proxy.$vuetify.breakpoint.smAndDown || false;
const mdAndDown = getCurrentInstance()?.proxy.$vuetify.breakpoint.mdAndDown || false;
const lgAndDown = getCurrentInstance()?.proxy.$vuetify.breakpoint.lgAndDown || false;
const xl = getCurrentInstance()?.proxy.$vuetify.breakpoint.xl || false;
if(smAndDown || useCameraSettingsStore().isDriverMode || (mdAndDown && !useStateStore().sidebarFolded)) {
return [Object.values(allTabs)];
} else if(mdAndDown || !useStateStore().sidebarFolded) {
return [
[allTabs.inputTab, allTabs.thresholdTab, allTabs.contoursTab, allTabs.apriltagTab, allTabs.arucoTab, allTabs.outputTab],
[allTabs.targetsTab, allTabs.pnpTab, allTabs.map3dTab]
];
} else if(lgAndDown) {
return [
[allTabs.inputTab],
[allTabs.thresholdTab, allTabs.contoursTab, allTabs.apriltagTab, allTabs.arucoTab, allTabs.outputTab],
[allTabs.targetsTab, allTabs.pnpTab, allTabs.map3dTab]
];
} else if(xl) {
return [
[allTabs.inputTab],
[allTabs.thresholdTab],
[allTabs.contoursTab, allTabs.apriltagTab, allTabs.arucoTab, allTabs.outputTab],
[allTabs.targetsTab, allTabs.pnpTab, allTabs.map3dTab]
];
}
return [];
};
const tabGroups = computed<ConfigOption[][]>(() => {
// Just return the input tab because we know that is always the case in driver mode
if(useCameraSettingsStore().isDriverMode) return [[allTabs.inputTab]];
const allow3d = useCameraSettingsStore().currentPipelineSettings.solvePNPEnabled;
const isAprilTag = useCameraSettingsStore().currentWebsocketPipelineType === WebsocketPipelineType.AprilTag;
const isAruco = useCameraSettingsStore().currentWebsocketPipelineType === WebsocketPipelineType.Aruco;
return getTabGroups().map(tabGroup => tabGroup.filter(tabConfig =>
!(!allow3d && tabConfig.tabName === "3D") //Filter out 3D tab any time 3D isn't calibrated
&& !((!allow3d || isAprilTag || isAruco) && tabConfig.tabName === "PnP") //Filter out the PnP config tab if 3D isn't available, or we're doing AprilTags
&& !((isAprilTag || isAruco) && (tabConfig.tabName === "Threshold")) //Filter out threshold tab if we're doing AprilTags
&& !((isAprilTag || isAruco) && (tabConfig.tabName === "Contours")) //Filter out contours if we're doing AprilTags
&& !(!isAprilTag && tabConfig.tabName === "AprilTag") //Filter out apriltag unless we actually are doing AprilTags
&& !(!isAruco && tabConfig.tabName === "Aruco") //Filter out aruco unless we actually are doing Aruco
));
});
onBeforeUpdate(() => {
// Force the current tab to the input tab on driver mode change
if(useCameraSettingsStore().isDriverMode) {
selectedTabs.value[0] = 0;
}
});
</script>
<template>
<v-row
no-gutters
class="tabGroups"
>
<v-col
v-for="(tabGroupData, tabGroupIndex) in tabGroups"
:key="tabGroupIndex"
:class="tabGroupIndex !== tabGroups.length - 1 && 'pr-3'"
>
<v-card
color="primary"
height="100%"
class="pr-4 pl-4"
>
<v-tabs
v-model="selectedTabs[tabGroupIndex]"
grow
background-color="primary"
dark
height="48"
slider-color="accent"
>
<v-tab
v-for="(tabConfig, index) in tabGroupData"
:key="index"
>
{{ tabConfig.tabName }}
</v-tab>
</v-tabs>
<div class="pl-4 pr-4 pt-4 pb-2">
<KeepAlive>
<Component :is="tabGroupData[selectedTabs[tabGroupIndex]].component" />
</KeepAlive>
</div>
</v-card>
</v-col>
</v-row>
</template>
<style>
.v-slide-group__next--disabled, .v-slide-group__prev--disabled {
display: none !important;
}
</style>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import { computed } from "vue";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { useStateStore } from "@/stores/StateStore";
const props = defineProps<{
// TODO fully update v-model usage in custom components on Vue3 update
value: number[]
}>();
const emit = defineEmits<{
(e: "input", value: number[]): void
}>();
const localValue = computed({
get: () => props.value,
set: v => emit("input", v)
});
const processingMode = computed<number>({
get: () => useCameraSettingsStore().currentPipelineSettings.solvePNPEnabled ? 1: 0,
set: v => {
if(useCameraSettingsStore().isCurrentVideoFormatCalibrated) {
useCameraSettingsStore().changeCurrentPipelineSetting({ solvePNPEnabled: v === 1 }, true);
}
}
});
</script>
<template>
<v-card
:disabled="useCameraSettingsStore().isDriverMode || useStateStore().colorPickingMode"
class="mt-3"
color="primary"
style="height: 100%; display: flex; flex-direction: column"
>
<v-row
align="center"
class="pa-3 pb-0"
>
<v-col>
<p style="color: white;">
Processing Mode
</p>
<v-btn-toggle
v-model="processingMode"
mandatory
dark
class="fill"
>
<v-btn
color="secondary"
>
<v-icon>mdi-square-outline</v-icon>
<span>2D</span>
</v-btn>
<v-btn
color="secondary"
:disabled="!useCameraSettingsStore().isCurrentVideoFormatCalibrated"
>
<v-icon>mdi-cube-outline</v-icon>
<span>3D</span>
</v-btn>
</v-btn-toggle>
</v-col>
</v-row>
<v-row
align="center"
class="pa-3 pt-0"
>
<v-col>
<p style="color: white;">
Stream Display
</p>
<v-btn-toggle
v-model="localValue"
:multiple="true"
mandatory
dark
class="fill"
>
<v-btn
color="secondary"
class="fill"
>
<v-icon>mdi-import</v-icon>
<span>Raw</span>
</v-btn>
<v-btn
color="secondary"
class="fill"
>
<v-icon>mdi-export</v-icon>
<span>Processed</span>
</v-btn>
</v-btn-toggle>
</v-col>
</v-row>
</v-card>
</template>
<style scoped>
.v-btn-toggle.fill {
width: 100%;
height: 100%;
}
.v-btn-toggle.fill > .v-btn {
width: 50%;
height: 100%;
}
th {
width: 80px;
text-align: center;
}
</style>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { PipelineType } from "@/types/PipelineTypes";
import CvSelect from "@/components/common/cv-select.vue";
import CvSlider from "@/components/common/cv-slider.vue";
import CvSwitch from "@/components/common/cv-switch.vue";
import { computed, getCurrentInstance } from "vue";
import { useStateStore } from "@/stores/StateStore";
// TODO fix pipeline typing in order to fix this, the store settings call should be able to infer that only valid pipeline type settings are exposed based on pre-checks for the entire config section
// Defer reference to store access method
const currentPipelineSettings = useCameraSettingsStore().currentPipelineSettings;
const interactiveCols = computed(() => (getCurrentInstance()?.proxy.$vuetify.breakpoint.mdAndDown || false) && (!useStateStore().sidebarFolded || useCameraSettingsStore().isDriverMode)) ? 9 : 8;
</script>
<template>
<div v-if="currentPipelineSettings.pipelineType === PipelineType.AprilTag">
<cv-select
v-model="currentPipelineSettings.tagFamily"
label="Target family"
:items="['AprilTag Family 36h11', 'AprilTag Family 25h9', 'AprilTag Family 16h5']"
:select-cols="interactiveCols"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({tagFamily: value}, false)"
/>
<cv-slider
v-model="currentPipelineSettings.decimate"
class="pt-2"
:slider-cols="interactiveCols"
label="Decimate"
tooltip="Increases FPS at the expense of range by reducing image resolution initially"
:min="1"
:max="8"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({decimate: value}, false)"
/>
<cv-slider
v-model="currentPipelineSettings.blur"
class="pt-2"
:slider-cols="interactiveCols"
label="Blur"
tooltip="Gaussian blur added to the image, high FPS cost for slightly decreased noise"
:min="0"
:max="5"
:step="0.1"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({blur: value}, false)"
/>
<cv-slider
v-model="currentPipelineSettings.threads"
class="pt-2"
:slider-cols="interactiveCols"
label="Threads"
tooltip="Number of threads spawned by the AprilTag detector"
:min="1"
:max="8"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({threads: value}, false)"
/>
<cv-switch
v-model="currentPipelineSettings.refineEdges"
class="pt-2"
label="Refine Edges"
tooltip="Further refines the AprilTag corner position initial estimate, suggested left on"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({refineEdges: value}, false)"
/>
<cv-slider
v-model="currentPipelineSettings.decisionMargin"
class="pt-2 pb-4"
:slider-cols="interactiveCols"
label="Decision Margin Cutoff"
tooltip="Tags with a 'margin' (decoding quality score) less than this wil be rejected. Increase this to reduce the number of false positive detections"
:min="0"
:max="250"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({decisionMargin: value}, false)"
/>
<cv-slider
v-model="currentPipelineSettings.numIterations"
class="pt-2 pb-4"
:slider-cols="interactiveCols"
label="Pose Estimation Iterations"
tooltip="Number of iterations the pose estimation algorithm will run, 50-100 is a good starting point"
:min="0"
:max="500"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({numIterations: value}, false)"
/>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { PipelineType } from "@/types/PipelineTypes";
import CvSlider from "@/components/common/cv-slider.vue";
import { computed, getCurrentInstance } from "vue";
import { useStateStore } from "@/stores/StateStore";
// TODO fix pipeline typing in order to fix this, the store settings call should be able to infer that only valid pipeline type settings are exposed based on pre-checks for the entire config section
// Defer reference to store access method
const currentPipelineSettings = useCameraSettingsStore().currentPipelineSettings;
const interactiveCols = computed(() => (getCurrentInstance()?.proxy.$vuetify.breakpoint.mdAndDown || false) && (!useStateStore().sidebarFolded || useCameraSettingsStore().isDriverMode)) ? 9 : 8;
</script>
<template>
<div v-if="currentPipelineSettings.pipelineType === PipelineType.Aruco">
<cv-slider
v-model="currentPipelineSettings.decimate"
class="pt-2"
:slider-cols="interactiveCols"
label="Decimate"
tooltip="Increases FPS at the expense of range by reducing image resolution initially"
:min="1"
:max="8"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({decimate: value}, false)"
/>
<cv-slider
v-model="currentPipelineSettings.numIterations"
class="pt-2"
:slider-cols="interactiveCols"
label="Corner Iterations"
tooltip="How many iterations are going to be used in order to refine corners. Higher values are lead to more accuracy at the cost of performance"
:min="30"
:max="1000"
:step="5"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({numIterations: value}, false)"
/>
<cv-slider
v-model="currentPipelineSettings.cornerAccuracy"
class="pt-2"
:slider-cols="interactiveCols"
label="Corner Accuracy"
tooltip="Minimum accuracy for the corners, lower is better but more performance intensive "
:min="0.01"
:max="100"
:step="0.01"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({cornerAccuracy: value}, false)"
/>
</div>
</template>

View File

@@ -0,0 +1,213 @@
<script setup lang="ts">
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { PipelineType } from "@/types/PipelineTypes";
import CvRangeSlider from "@/components/common/cv-range-slider.vue";
import CvSelect from "@/components/common/cv-select.vue";
import CvSlider from "@/components/common/cv-slider.vue";
import { computed, getCurrentInstance } from "vue";
import { useStateStore } from "@/stores/StateStore";
// TODO fix pipeline typing in order to fix this, the store settings call should be able to infer that only valid pipeline type settings are exposed based on pre-checks for the entire config section
// Defer reference to store access method
const currentPipelineSettings = useCameraSettingsStore().currentPipelineSettings;
// TODO fix cv-range-slider so that store access doesn't need to be deferred
const contourArea = computed<[number, number]>({
get: () => Object.values(useCameraSettingsStore().currentPipelineSettings.contourArea) as [number, number],
set: v => useCameraSettingsStore().currentPipelineSettings.contourArea = v
});
const contourRatio = computed<[number, number]>({
get: () => Object.values(useCameraSettingsStore().currentPipelineSettings.contourRatio) as [number, number],
set: v => useCameraSettingsStore().currentPipelineSettings.contourRatio = v
});
const contourFullness = computed<[number, number]>({
get: () => Object.values(useCameraSettingsStore().currentPipelineSettings.contourFullness) as [number, number],
set: v => useCameraSettingsStore().currentPipelineSettings.contourFullness = v
});
const contourPerimeter = computed<[number, number]>({
get: () => currentPipelineSettings.pipelineType === PipelineType.ColoredShape ? Object.values(currentPipelineSettings.contourPerimeter) as [number, number] : [0, 0] as [number, number],
set: v => {
if(currentPipelineSettings.pipelineType === PipelineType.ColoredShape) {
currentPipelineSettings.contourPerimeter = v;
}
}
});
const contourRadius = computed<[number, number]>({
get: () => currentPipelineSettings.pipelineType === PipelineType.ColoredShape ? Object.values(currentPipelineSettings.contourRadius) as [number, number] : [0, 0] as [number, number],
set: v => {
if(currentPipelineSettings.pipelineType === PipelineType.ColoredShape) {
currentPipelineSettings.contourRadius = v;
}
}
});
const interactiveCols = computed(() => (getCurrentInstance()?.proxy.$vuetify.breakpoint.mdAndDown || false) && (!useStateStore().sidebarFolded || useCameraSettingsStore().isDriverMode)) ? 9 : 8;
</script>
<template>
<div>
<cv-range-slider
v-model="contourArea"
label="Area"
:min="0"
:max="100"
:slider-cols="interactiveCols"
:step="0.01"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({contourArea: value}, false)"
/>
<cv-range-slider
v-if="useCameraSettingsStore().currentPipelineType !== PipelineType.ColoredShape"
v-model="contourRatio"
label="Ratio (W/H)"
tooltip="Min and max ratio between the width and height of a contour's bounding rectangle"
:min="0"
:max="100"
:slider-cols="interactiveCols"
:step="0.1"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({contourRatio: value}, false)"
/>
<cv-select
v-model="useCameraSettingsStore().currentPipelineSettings.contourTargetOrientation"
label="Target Orientation"
tooltip="Used to determine how to calculate target landmarks, as well as aspect ratio"
:items="['Portrait', 'Landscape']"
:select-cols="interactiveCols"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({contourTargetOrientation: value}, false)"
/>
<cv-range-slider
v-if="useCameraSettingsStore().currentPipelineType === PipelineType.ColoredShape"
v-model="contourFullness"
label="Fullness"
tooltip="Min and max ratio between a contour's area and its bounding rectangle"
:min="0"
:max="100"
:slider-cols="interactiveCols"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({contourFullness: value}, false)"
/>
<cv-range-slider
v-if="currentPipelineSettings.pipelineType === PipelineType.ColoredShape"
v-model="contourPerimeter"
label="Perimeter"
tooltip="Min and max perimeter of the shape, in pixels"
min="0"
max="4000"
:slider-cols="interactiveCols"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({contourPerimeter: value}, false)"
/>
<cv-slider
v-model="useCameraSettingsStore().currentPipelineSettings.contourSpecklePercentage"
label="Speckle Rejection"
tooltip="Rejects contours whose average area is less than the given percentage of the average area of all the other contours"
:min="0"
:max="100"
:slider-cols="interactiveCols"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({contourSpecklePercentage: value}, false)"
/>
<template v-if="currentPipelineSettings.pipelineType === PipelineType.Reflective">
<cv-slider
v-model="currentPipelineSettings.contourFilterRangeX"
label="X Filter Tightness"
tooltip="Rejects contours whose center X is further than X standard deviations left/right of the mean X location"
:min="0.1"
:max="4"
:step="0.1"
:slider-cols="interactiveCols"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({contourFilterRangeX: value}, false)"
/>
<cv-slider
v-model="currentPipelineSettings.contourFilterRangeY"
label="Y Filter Tightness"
tooltip="Rejects contours whose center Y is further than X standard deviations above/below the mean Y location"
:min="0.1"
:max="4"
:step="0.1"
:slider-cols="interactiveCols"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({contourFilterRangeY: value}, false)"
/>
<cv-select
v-model="useCameraSettingsStore().currentPipelineSettings.contourGroupingMode"
label="Target Grouping"
tooltip="Whether or not every two targets are paired with each other (good for e.g. 2019 targets)"
:select-cols="interactiveCols"
:items="['Single','Dual','Two or More']"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({contourGroupingMode: value}, false)"
/>
<cv-select
v-model="useCameraSettingsStore().currentPipelineSettings.contourIntersection"
label="Target Intersection"
tooltip="If target grouping is in dual mode it will use this dropdown to decide how targets are grouped with adjacent targets"
:select-cols="interactiveCols"
:items="['None','Up','Down','Left','Right']"
:disabled="useCameraSettingsStore().currentPipelineSettings.contourGroupingMode === 0"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({contourIntersection: value}, false)"
/>
</template>
<template v-else-if="currentPipelineSettings.pipelineType === PipelineType.ColoredShape">
<v-divider class="mt-3" />
<cv-select
v-model="currentPipelineSettings.contourShape"
label="Target Shape"
tooltip="The shape of targets to look for"
:select-cols="interactiveCols"
:items="['Circle', 'Polygon', 'Triangle', 'Quadrilateral']"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({contourShape: value}, false)"
/>
<cv-slider
v-model="currentPipelineSettings.accuracyPercentage"
:disabled="currentPipelineSettings.contourShape < 1"
label="Shape Simplification"
tooltip="How much we should simply the input contour before checking how many sides it has"
:min="0"
:max="100"
:slider-cols="interactiveCols"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({accuracyPercentage: value}, false)"
/>
<cv-slider
v-model="currentPipelineSettings.circleDetectThreshold"
:disabled="currentPipelineSettings.contourShape !== 0"
label="Circle match distance"
tooltip="How close the centroid of a contour must be to the center of a circle in order for them to be matched"
:min="1"
:max="100"
:slider-cols="interactiveCols"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({circleDetectThreshold: value}, false)"
/>
<cv-range-slider
v-model="contourRadius"
:disabled="currentPipelineSettings.contourShape !== 0"
label="Radius"
:min="0"
:max="100"
:slider-cols="interactiveCols"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({contourRadius: value}, false)"
/>
<cv-slider
v-model="currentPipelineSettings.maxCannyThresh"
:disabled="currentPipelineSettings.contourShape !== 0"
label="Max Canny Threshold"
:min="1"
:max="100"
:slider-cols="interactiveCols"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({maxCannyThresh: value}, false)"
/>
<cv-slider
v-model="currentPipelineSettings.circleAccuracy"
:disabled="currentPipelineSettings.contourShape !== 0"
label="Circle Accuracy"
:min="1"
:max="100"
:slider-cols="interactiveCols"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({circleAccuracy: value}, false)"
/>
<v-divider class="mt-3" />
</template>
<cv-select
v-model="useCameraSettingsStore().currentPipelineSettings.contourSortMode"
label="Target Sort"
tooltip="Chooses the sorting mode used to determine the 'best' targets to provide to user code"
:select-cols="interactiveCols"
:items="['Largest','Smallest','Highest','Lowest','Rightmost','Leftmost','Centermost']"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({contourSortMode: value}, false)"
/>
</div>
</template>

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import CvSlider from "@/components/common/cv-slider.vue";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import CvSwitch from "@/components/common/cv-switch.vue";
import CvSelect from "@/components/common/cv-select.vue";
import { computed, getCurrentInstance } from "vue";
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
import { useStateStore } from "@/stores/StateStore";
// Due to something with libcamera or something else IDK much about, the 90° rotations need to be disabled if the libcamera drivers are being used.
const cameraRotations = computed(() => ["Normal", "90° CW", "180°", "90° CCW"].map((v, i) => ({ name: v, value: i, disabled: useSettingsStore().gpuAccelerationEnabled ? [1, 3].includes(i) : false })));
const streamDivisors = [1, 2, 4, 6];
const getFilteredStreamDivisors = (): number[] => {
const currentResolutionWidth = useCameraSettingsStore().currentVideoFormat.resolution.width;
return streamDivisors.filter(x =>
useCameraSettingsStore().isDriverMode
|| !useSettingsStore().gpuAccelerationEnabled
|| currentResolutionWidth / x < 400);
};
const getNumberOfSkippedDivisors = () => streamDivisors.length - getFilteredStreamDivisors().length;
const cameraResolutions = computed(() => useCameraSettingsStore().currentCameraSettings.validVideoFormats.map(f => `${f.resolution.width} X ${f.resolution.height} at ${f.fps} FPS, ${f.pixelFormat}`));
const handleResolutionChange = (value: number) => {
useCameraSettingsStore().changeCurrentPipelineSetting({ cameraVideoModeIndex: value }, false);
useCameraSettingsStore().changeCurrentPipelineSetting({ streamingFrameDivisor: getNumberOfSkippedDivisors() }, false);
useCameraSettingsStore().currentPipelineSettings.streamingFrameDivisor = 0;
if(!useCameraSettingsStore().isCurrentVideoFormatCalibrated) {
useCameraSettingsStore().changeCurrentPipelineSetting({ solvePNPEnabled: false }, true);
}
};
const streamResolutions = computed(() => {
const streamDivisors = getFilteredStreamDivisors();
const currentResolution = useCameraSettingsStore().currentVideoFormat.resolution;
return streamDivisors
.map(x => `${Math.floor(currentResolution.width / x)} X ${Math.floor(currentResolution.height / x)}`);
});
const handleStreamResolutionChange = (value: number) => {
useCameraSettingsStore().changeCurrentPipelineSetting({ streamingFrameDivisor: value + getNumberOfSkippedDivisors() }, false);
};
const interactiveCols = computed(() => (getCurrentInstance()?.proxy.$vuetify.breakpoint.mdAndDown || false) && (!useStateStore().sidebarFolded || useCameraSettingsStore().isDriverMode)) ? 9 : 8;
</script>
<template>
<div>
<cv-slider
v-model="useCameraSettingsStore().currentPipelineSettings.cameraExposure"
:disabled="useCameraSettingsStore().currentCameraSettings.pipelineSettings.cameraAutoExposure"
label="Exposure"
tooltip="Directly controls how much light is allowed to fall onto the sensor, which affects apparent brightness"
:min="0"
:max="100"
:slider-cols="interactiveCols"
:step="0.1"
@input="args => useCameraSettingsStore().changeCurrentPipelineSetting({cameraExposure: args}, false)"
/>
<cv-slider
v-model="useCameraSettingsStore().currentPipelineSettings.cameraBrightness"
label="Brightness"
:min="0"
:max="100"
:slider-cols="interactiveCols"
@input="args => useCameraSettingsStore().changeCurrentPipelineSetting({cameraBrightness: args}, false)"
/>
<cv-switch
v-model="useCameraSettingsStore().currentPipelineSettings.cameraAutoExposure"
class="pt-2"
label="Auto Exposure"
:switch-cols="interactiveCols"
tooltip="Enables or Disables camera automatic adjustment for current lighting conditions"
@input="args => useCameraSettingsStore().changeCurrentPipelineSetting({cameraAutoExposure: args}, false)"
/>
<cv-slider
v-if="useCameraSettingsStore().currentPipelineSettings.cameraGain >= 0"
v-model="useCameraSettingsStore().currentPipelineSettings.cameraGain"
label="Camera Gain"
tooltip="Controls camera gain, similar to brightness"
:min="0"
:max="100"
:slider-cols="interactiveCols"
@input="args => useCameraSettingsStore().changeCurrentPipelineSetting({cameraGain: args}, false)"
/>
<cv-slider
v-if="useCameraSettingsStore().currentPipelineSettings.cameraRedGain !== -1"
v-model="useCameraSettingsStore().currentPipelineSettings.cameraRedGain"
label="Red AWB Gain"
:min="0"
:max="100"
:slider-cols="interactiveCols"
tooltip="Controls red automatic white balance gain, which affects how the camera captures colors in different conditions"
@input="args => useCameraSettingsStore().changeCurrentPipelineSetting({cameraRedGain: args}, false)"
/>
<cv-slider
v-if="useCameraSettingsStore().currentPipelineSettings.cameraBlueGain !== -1"
v-model="useCameraSettingsStore().currentPipelineSettings.cameraBlueGain"
label="Blue AWB Gain"
:min="0"
:max="100"
:slider-cols="interactiveCols"
tooltip="Controls blue automatic white balance gain, which affects how the camera captures colors in different conditions"
@input="args => useCameraSettingsStore().changeCurrentPipelineSetting({cameraBlueGain: args}, false)"
/>
<cv-select
v-model="useCameraSettingsStore().currentPipelineSettings.inputImageRotationMode"
label="Orientation"
tooltip="Rotates the camera stream"
:items="cameraRotations"
:select-cols="interactiveCols"
@input="args => useCameraSettingsStore().changeCurrentPipelineSetting({inputImageRotationMode: args}, false)"
/>
<cv-select
v-model="useCameraSettingsStore().currentPipelineSettings.cameraVideoModeIndex"
label="Resolution"
tooltip="Resolution and FPS the camera should directly capture at"
:items="cameraResolutions"
:select-cols="interactiveCols"
@input="args => handleResolutionChange(args)"
/>
<cv-select
v-model="useCameraSettingsStore().currentPipelineSettings.streamingFrameDivisor"
label="Stream Resolution"
tooltip="Resolution to which camera frames are downscaled for streaming to the dashboard"
:items="streamResolutions"
:select-cols="interactiveCols"
@input="args => handleStreamResolutionChange(args)"
/>
</div>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { computed } from "vue";
import type { PhotonTarget } from "@/types/PhotonTrackingTypes";
import { useStateStore } from "@/stores/StateStore";
import Photon3dVisualizer from "@/components/app/photon-3d-visualizer.vue";
const trackedTargets = computed<PhotonTarget[]>(() => useStateStore().pipelineResults?.targets || []);
</script>
<template>
<div>
<v-row style="width: 100%">
<v-col>
<span class="white--text">Target Visualization</span>
</v-col>
</v-row>
<v-row style="width: 100%">
<v-col style="display: flex; align-items: center; justify-content: center">
<photon3d-visualizer
:targets="trackedTargets"
/>
</v-col>
</v-row>
</div>
</template>

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import CvSelect from "@/components/common/cv-select.vue";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { PipelineType, RobotOffsetPointMode } from "@/types/PipelineTypes";
import CvSwitch from "@/components/common/cv-switch.vue";
import { computed, getCurrentInstance } from "vue";
import { RobotOffsetType } from "@/types/SettingTypes";
import { useStateStore } from "@/stores/StateStore";
const isTagPipeline = computed(() => useCameraSettingsStore().currentPipelineType === PipelineType.AprilTag || useCameraSettingsStore().currentPipelineType === PipelineType.Aruco);
interface MetricItem {
header: string,
value?: string
}
const offsetPoints = computed<MetricItem[]>(() => {
switch (useCameraSettingsStore().currentPipelineSettings.offsetRobotOffsetMode) {
case RobotOffsetPointMode.Single:
const value = Object.values(useCameraSettingsStore().currentPipelineSettings.offsetSinglePoint);
return [{ header: "Offset Point", value: `(${value[0].toFixed(2)}°, ${value[1].toFixed(2)}°)` }];
case RobotOffsetPointMode.Dual:
const firstPoint = Object.values(useCameraSettingsStore().currentPipelineSettings.offsetDualPointA);
const firstPointArea = useCameraSettingsStore().currentPipelineSettings.offsetDualPointAArea;
const secondPoint = Object.values(useCameraSettingsStore().currentPipelineSettings.offsetDualPointB);
const secondPointArea = useCameraSettingsStore().currentPipelineSettings.offsetDualPointBArea;
return [{ header: "First Offset Point", value: `(${firstPoint[0].toFixed(2)}°, ${firstPoint[1].toFixed(2)}°)` },
{ header: "First Offset Point Area", value: `${firstPointArea.toFixed(2)}%` },
{ header: "Second Offset Point", value: `(${secondPoint[0].toFixed(2)}°, ${secondPoint[1].toFixed(2)}°)` },
{ header: "Second Offset Point Area", value: `${secondPointArea.toFixed(2)}%` }
];
default:
case RobotOffsetPointMode.None:
return [];
}
});
const interactiveCols = computed(() => (getCurrentInstance()?.proxy.$vuetify.breakpoint.mdAndDown || false) && (!useStateStore().sidebarFolded || useCameraSettingsStore().isDriverMode)) ? 9 : 8;
</script>
<template>
<div>
<cv-select
v-model="useCameraSettingsStore().currentPipelineSettings.contourTargetOffsetPointEdge"
label="Target Offset Point"
tooltip="Changes where the 'center' of the target is (used for calculating e.g. pitch and yaw)"
:items="['Center','Top','Bottom','Left','Right']"
:select-cols="interactiveCols"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({contourTargetOffsetPointEdge: value}, false)"
/>
<cv-select
v-if="!isTagPipeline"
v-model="useCameraSettingsStore().currentPipelineSettings.contourTargetOrientation"
label="Target Orientation"
tooltip="Used to determine how to calculate target landmarks (e.g. the top, left, or bottom of the target)"
:items="['Portrait', 'Landscape']"
:select-cols="interactiveCols"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({contourTargetOrientation: value}, false)"
/>
<cv-switch
v-model="useCameraSettingsStore().currentPipelineSettings.outputShowMultipleTargets"
label="Show Multiple Targets"
tooltip="If enabled, up to five targets will be displayed and sent to user code, instead of just one"
:disabled="isTagPipeline"
:switch-cols="interactiveCols"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({outputShowMultipleTargets: value}, false)"
/>
<v-divider />
<table
v-if="useCameraSettingsStore().currentPipelineSettings.offsetRobotOffsetMode !== RobotOffsetPointMode.None"
class="metrics-table mt-3 mb-3"
>
<tr>
<th
v-for="(item, itemIndex) in offsetPoints"
:key="itemIndex"
class="metric-item metric-item-title"
>
{{ item.header }}
</th>
</tr>
<tr>
<td
v-for="(item, itemIndex) in offsetPoints"
:key="itemIndex"
class="metric-item"
>
{{ item.value }}
</td>
</tr>
</table>
<cv-select
v-model="useCameraSettingsStore().currentPipelineSettings.offsetRobotOffsetMode"
label="Robot Offset Mode"
tooltip="Used to add an arbitrary offset to the location of the targeting crosshair"
:items="['None','Single Point','Dual Point']"
:select-cols="interactiveCols"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({offsetRobotOffsetMode: value}, false)"
/>
<v-row
v-if="useCameraSettingsStore().currentPipelineSettings.offsetRobotOffsetMode !== RobotOffsetPointMode.None"
align="center"
justify="start"
>
<v-row v-if="useCameraSettingsStore().currentPipelineSettings.offsetRobotOffsetMode === RobotOffsetPointMode.Single">
<v-col>
<v-btn
small
color="accent"
style="width: 100%;"
class="black--text"
@click="useCameraSettingsStore().takeRobotOffsetPoint(RobotOffsetType.Single)"
>
Take Point
</v-btn>
</v-col>
</v-row>
<v-row v-else-if="useCameraSettingsStore().currentPipelineSettings.offsetRobotOffsetMode === RobotOffsetPointMode.Dual">
<v-col>
<v-btn
small
color="accent"
style="width: 100%;"
class="black--text"
@click="useCameraSettingsStore().takeRobotOffsetPoint(RobotOffsetType.DualFirst)"
>
Take First Point
</v-btn>
</v-col>
<v-col>
<v-btn
small
color="accent"
style="width: 100%;"
class="black--text"
@click="useCameraSettingsStore().takeRobotOffsetPoint(RobotOffsetType.DualSecond)"
>
Take Second Point
</v-btn>
</v-col>
</v-row>
<v-col>
<v-btn
small
color="yellow darken-3"
style="width: 100%;"
@click="useCameraSettingsStore().takeRobotOffsetPoint(RobotOffsetType.Clear)"
>
Clear All Points
</v-btn>
</v-col>
</v-row>
</div>
</template>
<style scoped>
.metrics-table{
border-collapse: separate;
border-spacing: 0;
border-radius: 5px;
border: 1px solid white;
width: 100%;
text-align: center;
}
.metric-item {
padding: 1px 15px 1px 10px;
border-right: 1px solid;
font-weight: normal;
color: white;
}
.metric-item-title {
font-size: 18px;
text-decoration: underline;
text-decoration-color: #ffd843;
}
</style>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import CvSelect from "@/components/common/cv-select.vue";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { TargetModel } from "@/types/PipelineTypes";
import CvSlider from "@/components/common/cv-slider.vue";
import { computed, getCurrentInstance } from "vue";
import { useStateStore } from "@/stores/StateStore";
const interactiveCols = computed(() => (getCurrentInstance()?.proxy.$vuetify.breakpoint.mdAndDown || false) && (!useStateStore().sidebarFolded || useCameraSettingsStore().isDriverMode)) ? 9 : 8;
</script>
<template>
<div>
<cv-select
v-model="useCameraSettingsStore().currentPipelineSettings.targetModel"
label="Target Model"
:items="[
{name: '2020 High Goal Outer', value: TargetModel.InfiniteRechargeHighGoalOuter},
{name: '2020 High Goal Inner', value: TargetModel.InfiniteRechargeHighGoalInner},
{name: '2019 Dual Target', value: TargetModel.DeepSpaceDualTarget},
{name: '2020 Power Cell (7in)', value: TargetModel.CircularPowerCell7in},
{name: '2022 Cargo Ball (9.5in)', value: TargetModel.RapidReactCircularCargoBall},
{name: '2016 High Goal', value: TargetModel.StrongholdHighGoal},
{name: '200mm AprilTag', value: TargetModel.Apriltag_200mm},
{name: '6in (16h5) Aruco', value: TargetModel.Aruco6in_16h5},
{name: '6in (16h5) AprilTag', value: TargetModel.Apriltag6in_16h5}
]"
:select-cols="interactiveCols"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({targetModel: value}, false)"
/>
<cv-slider
v-model="useCameraSettingsStore().currentPipelineSettings.cornerDetectionAccuracyPercentage"
class="pt-2"
:slider-cols="interactiveCols"
label="Contour simplification Percentage"
:min="0"
:max="100"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({cornerDetectionAccuracyPercentage: value}, false)"
/>
</div>
</template>

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { PipelineType } from "@/types/PipelineTypes";
import { useStateStore } from "@/stores/StateStore";
</script>
<template>
<div>
<v-row
align="start"
class="pb-4"
style="height: 300px;"
>
<!-- Simple table height must be set here and in the CSS for the fixed-header to work -->
<v-simple-table
fixed-header
height="100%"
dense
dark
>
<template #default>
<thead style="font-size: 1.25rem;">
<tr>
<th class="text-center">
Target Count
</th>
<th
v-if="useCameraSettingsStore().currentPipelineType === PipelineType.AprilTag || useCameraSettingsStore().currentPipelineType === PipelineType.Aruco"
class="text-center"
>
Fiducial ID
</th>
<template v-if="!useCameraSettingsStore().currentPipelineSettings.solvePNPEnabled">
<th class="text-center">
Pitch &theta;&deg;
</th>
<th class="text-center">
Yaw &theta;&deg;
</th>
<th class="text-center">
Skew &theta;&deg;
</th>
<th class="text-center">
Area %
</th>
</template>
<template v-else>
<th class="text-center">
X meters
</th>
<th class="text-center">
Y meters
</th>
<th class="text-center">
Z Angle &theta;&deg;
</th>
</template>
<template v-if="useCameraSettingsStore().currentPipelineType === PipelineType.AprilTag && useCameraSettingsStore().currentPipelineSettings.solvePNPEnabled">
<th class="text-center">
Ambiguity %
</th>
</template>
</tr>
</thead>
<tbody>
<tr
v-for="(target, index) in useStateStore().pipelineResults?.targets"
:key="index"
>
<td>{{ index }}</td>
<td v-if="useCameraSettingsStore().currentPipelineType === PipelineType.AprilTag || useCameraSettingsStore().currentPipelineType === PipelineType.Aruco">
{{ target.fiducialId }}
</td>
<template v-if="!useCameraSettingsStore().currentPipelineSettings.solvePNPEnabled">
<td>{{ target.pitch.toFixed(2) }}&deg;</td>
<td>{{ target.yaw.toFixed(2) }}&deg;</td>
<td>{{ target.skew.toFixed(2) }}&deg;</td>
<td>{{ target.area.toFixed(2) }}&deg;</td>
</template>
<template v-else-if="useCameraSettingsStore().currentPipelineSettings.solvePNPEnabled">
<td>{{ target.pose?.x.toFixed(2) }}&nbsp;m</td>
<td>{{ target.pose?.y.toFixed(2) }}&nbsp;m</td>
<td>{{ (target.pose?.angle_z * 180.0 / Math.PI).toFixed(2) }}&deg;</td>
</template>
<template v-if="useCameraSettingsStore().currentPipelineType === PipelineType.AprilTag && useCameraSettingsStore().currentPipelineSettings.solvePNPEnabled">
<td>{{ target.ambiguity?.toFixed(2) }}%</td>
</template>
</tr>
</tbody>
</template>
</v-simple-table>
</v-row>
</div>
</template>
<style scoped lang="scss">
.v-data-table {
width: 100%;
height: 100%;
text-align: center;
background-color: #006492 !important;
th, td {
background-color: #006492 !important;
font-size: 1rem !important;
}
td {
font-family: monospace !important;
}
tbody :hover td {
background-color: #005281 !important;
}
::-webkit-scrollbar {
width: 0;
height: 0.55em;
border-radius: 5px;
}
::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background-color: #ffd843;
border-radius: 10px;
}
}
</style>

View File

@@ -0,0 +1,255 @@
<script setup lang="ts">
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { computed, getCurrentInstance, onBeforeUnmount, onMounted } from "vue";
import CvRangeSlider from "@/components/common/cv-range-slider.vue";
import CvSwitch from "@/components/common/cv-switch.vue";
import { useStateStore } from "@/stores/StateStore";
import { ColorPicker, type HSV } from "@/lib/ColorPicker";
const averageHue = computed<number>(() => {
const isHueInverted = useCameraSettingsStore().currentPipelineSettings.hueInverted;
let val = Object.values(useCameraSettingsStore().currentPipelineSettings.hsvHue).reduce((a, b) => a + b, 0);
if(isHueInverted) val += 180;
if (val > 360) val -= 360;
return val;
});
// TODO fix cv-range-slider so that store access doesn't need to be deferred
const hsvHue = computed<[number, number]>({
get: () => Object.values(useCameraSettingsStore().currentPipelineSettings.hsvHue) as [number, number],
set: v => useCameraSettingsStore().currentPipelineSettings.hsvHue = v
});
const hsvSaturation = computed<[number, number]>({
get: () => Object.values(useCameraSettingsStore().currentPipelineSettings.hsvSaturation) as [number, number],
set: v => useCameraSettingsStore().currentPipelineSettings.hsvSaturation = v
});
const hsvValue = computed<[number, number]>({
get: () => Object.values(useCameraSettingsStore().currentPipelineSettings.hsvValue) as [number, number],
set: v => useCameraSettingsStore().currentPipelineSettings.hsvValue = v
});
let selectedEventMode: 0 | 1 | 2 | 3 = 0;
const handleStreamClick = (event: MouseEvent) => {
if(!useStateStore().colorPickingMode || selectedEventMode === 0) return;
const cameraStream = document.getElementById("input-camera-stream");
if(cameraStream === null) return;
const canvas = document.createElement("canvas");
canvas.width = cameraStream.clientWidth;
canvas.height = cameraStream.clientHeight;
// Get the (x, y) position of the click with (0, 0) in the top left corner
const rect = cameraStream.getBoundingClientRect();
const x = Math.round((event.clientX - rect.left) / rect.width * cameraStream.clientWidth);
const y = Math.round((event.clientY - rect.top) / rect.height * cameraStream.clientHeight);
const context = canvas.getContext("2d");
if(context === null) return;
context.drawImage(cameraStream as CanvasImageSource, 0, 0, cameraStream.clientWidth, cameraStream.clientHeight);
const colorPicker = new ColorPicker(context.getImageData(x, y, 1, 1).data);
// Calculate HSV values based on the mode
let selectedHSVData: [HSV, HSV] = [[0, 0, 0], [0, 0, 0]];
if(selectedEventMode === 1) {
selectedHSVData = colorPicker.selectedColorRange();
} else {
const currentHue = Object.values(useCameraSettingsStore().currentPipelineSettings.hsvHue);
const currentSaturation = Object.values(useCameraSettingsStore().currentPipelineSettings.hsvSaturation);
const currentValue = Object.values(useCameraSettingsStore().currentPipelineSettings.hsvValue);
const currentData: [HSV, HSV] = [
[currentHue[0], currentSaturation[0], currentValue[0]],
[currentHue[1], currentSaturation[1], currentValue[1]]
];
if(selectedEventMode === 2) {
selectedHSVData = colorPicker.expandColorRange(currentData);
} else if(selectedEventMode === 3) {
selectedHSVData = colorPicker.shrinkColorRange(currentData);
}
}
// Update the store and backend with the new HSV values
useCameraSettingsStore().changeCurrentPipelineSetting({
hsvHue: [selectedHSVData[0][0], selectedHSVData[1][0]],
hsvSaturation: [selectedHSVData[0][1], selectedHSVData[1][1]],
hsvValue: [selectedHSVData[0][2], selectedHSVData[1][2]]
}, true);
disableColorPicking();
};
// Put some default values in case color picking was enabled before the enableColorPicking method is called
let inputShowing = true;
let outputShowing = false;
const enableColorPicking = (mode: 1 | 2 | 3) => {
useStateStore().colorPickingMode = true;
inputShowing = useCameraSettingsStore().currentPipelineSettings.inputShouldShow;
outputShowing = useCameraSettingsStore().currentPipelineSettings.outputShouldShow;
useCameraSettingsStore().changeCurrentPipelineSetting({ outputShouldDraw: false, inputShouldShow: true, outputShouldShow: false }, true);
selectedEventMode = mode;
};
const disableColorPicking = () => {
useStateStore().colorPickingMode = false;
useCameraSettingsStore().changeCurrentPipelineSetting({ outputShouldDraw: true, inputShouldShow: inputShowing, outputShouldShow: outputShowing }, true);
selectedEventMode = 0;
};
onMounted(() => {
const cameraStream = document.getElementById("input-camera-stream");
if(cameraStream === null) return;
cameraStream.addEventListener("click", handleStreamClick);
});
onBeforeUnmount(() => {
const cameraStream = document.getElementById("input-camera-stream");
if(cameraStream === null) return;
cameraStream.removeEventListener("click", handleStreamClick);
});
const interactiveCols = computed(() => (getCurrentInstance()?.proxy.$vuetify.breakpoint.mdAndDown || false) && (!useStateStore().sidebarFolded || useCameraSettingsStore().isDriverMode)) ? 9 : 8;
</script>
<template>
<div
class="threshold-modifiers"
:style="{'--averageHue': averageHue}"
>
<cv-range-slider
id="hue-slider"
v-model="hsvHue"
:class="useCameraSettingsStore().currentPipelineSettings.hueInverted ? 'inverted-slider' : 'normal-slider'"
label="Hue"
tooltip="Describes color"
:min="0"
:max="180"
:slider-cols="interactiveCols"
:inverted="useCameraSettingsStore().currentPipelineSettings.hueInverted"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({hsvHue: value}, false)"
/>
<cv-range-slider
id="sat-slider"
v-model="hsvSaturation"
class="normal-slider"
label="Saturation"
tooltip="Describes colorfulness; the smaller this value the 'whiter' the color becomes"
:min="0"
:max="255"
:slider-cols="interactiveCols"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({hsvSaturation: value}, false)"
/>
<cv-range-slider
id="value-slider"
v-model="hsvValue"
class="normal-slider"
label="Value"
tooltip="Describes lightness; the smaller this value the 'blacker' the color becomes"
:min="0"
:max="255"
:slider-cols="interactiveCols"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({hsvValue: value}, false)"
/>
<cv-switch
v-model="useCameraSettingsStore().currentPipelineSettings.hueInverted"
label="Invert Hue"
:switch-cols="interactiveCols"
tooltip="Selects the hue range outside of the hue slider bounds instead of inside"
@input="value => useCameraSettingsStore().changeCurrentPipelineSetting({hueInverted: value}, false)"
/>
<v-divider
class="mt-3"
/>
<div>
<div class="pt-3 white--text">
Color Picker
</div>
<v-row
justify="center"
class="mt-3 mb-3"
>
<template v-if="!useStateStore().colorPickingMode">
<v-btn
color="accent"
class="ma-2 black--text"
small
@click="enableColorPicking(useCameraSettingsStore().currentPipelineSettings.hueInverted ? 2 : 3)"
>
<v-icon left>
mdi-minus
</v-icon>
Shrink Range
</v-btn>
<v-btn
color="accent"
class="ma-2 black--text"
small
@click="enableColorPicking(1)"
>
<v-icon left>
mdi-plus-minus
</v-icon>
{{ useCameraSettingsStore().currentPipelineSettings.hueInverted ? "Exclude" : "Set to" }} Average
</v-btn>
<v-btn
color="accent"
class="ma-2 black--text"
small
@click="enableColorPicking(useCameraSettingsStore().currentPipelineSettings.hueInverted ? 3: 2)"
>
<v-icon left>
mdi-plus
</v-icon>
Expand Range
</v-btn>
</template>
<template v-else>
<v-btn
color="accent"
class="ma-2 black--text"
style="width: 30%;"
small
@click="disableColorPicking"
>
Cancel
</v-btn>
</template>
</v-row>
</div>
</div>
</template>
<style scoped lang="css">
.threshold-modifiers {
--averageHue: 0;
}
#hue-slider >>> .v-slider {
background: linear-gradient( to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100% );
border-radius: 10px;
box-shadow: 0 0 5px #333, inset 0 0 3px #333;
}
#sat-slider >>> .v-slider {
background: linear-gradient( to right, #fff 0%, hsl(var(--averageHue), 100%, 50%) 100% );
border-radius: 10px;
box-shadow: 0 0 5px #333, inset 0 0 3px #333;
}
#value-slider >>> .v-slider {
background: linear-gradient( to right, #000 0%, hsl(var(--averageHue), 100%, 50%) 100% );
border-radius: 10px;
box-shadow: 0 0 5px #333, inset 0 0 3px #333;
}
>>> .v-slider__thumb {
outline: black solid thin;
}
.normal-slider >>> .v-slider__track-fill {
outline: black solid thin;
}
.inverted-slider >>> .v-slider__track-background {
outline: black solid thin;
}
</style>

View File

@@ -1,268 +0,0 @@
<template>
<div
id="MapContainer"
style="flex-grow:1"
>
<v-row>
<v-col
align="center"
cols="12"
>
<span class="white--text">Target Visualization</span>
</v-col>
</v-row>
<v-row>
<v-col
align="center"
cols="12"
align-self="stretch"
>
<canvas
id="canvasId"
style="width:100%;height:100%"
/>
</v-col>
<v-row style="margin-bottom: 24px">
<v-col style="display: flex; justify-content: center">
<v-btn
color="secondary"
@click="resetCamFirstPerson"
>
First Person
</v-btn>
</v-col>
<v-col style="display: flex; justify-content: center">
<v-btn
color="secondary"
@click="resetCamThirdPerson"
>
Third Person
</v-btn>
</v-col>
</v-row>
</v-row>
</div>
</template>
<script>
import {
ArrowHelper,
BoxGeometry,
ConeGeometry,
Mesh,
MeshNormalMaterial,
PerspectiveCamera,
Quaternion,
Scene,
Vector3,
Color,
WebGLRenderer
} from "three";
// This import conflicts with Three.js docs but is required for the build to succeed
import { TrackballControls } from "three/examples/jsm/controls/TrackballControls"
export default {
name: "MiniMap",
props: {
// eslint-disable-next-line vue/require-default-prop
targets: Array,
// eslint-disable-next-line vue/require-default-prop
horizontalFOV: Number
},
data() {
return {
scene: undefined,
cubes: [],
}
},
watch: {
targets: {
deep: true,
handler() {
this.drawTargets();
}
},
},
mounted() {
const scene = new Scene();
this.scene = scene;
const camera = new PerspectiveCamera(75, 800 / 800, 0.1, 1000);
this.camera = camera;
const canvas = document.getElementById("canvasId"); // getting the canvas element
this.canvas = canvas;
const renderer = new WebGLRenderer({"canvas": canvas});
this.renderer = renderer;
scene.background = new Color(0xa9a9a9)
//Set up resize handlers
this.onWindowResize();
window.addEventListener( 'resize', this.onWindowResize, false );
//Add the reference frame cues
this.refFrameCues = []
// coordinate system
this.refFrameCues.push(new ArrowHelper(new Vector3(1, 0, 0).normalize(), new Vector3(0, 0, 0),
1, // length
0xff0000,
0.1,
0.1,
))
this.refFrameCues.push(new ArrowHelper(new Vector3(0, 1, 0).normalize(), new Vector3(0, 0, 0),
1, // length
0x00ff00,
0.1,
0.1,
))
this.refFrameCues.push(new ArrowHelper(new Vector3(0, 0, 1).normalize(), new Vector3(0, 0, 0),
1, // length
0x0000ff,
0.1,
0.1,
))
//something that looks vaguely like a camera
const camSize = 0.2;
const camBodyGeometry = new BoxGeometry(camSize, camSize, camSize);
const camLensGeometry = new ConeGeometry(camSize*0.4, camSize*0.8, 30);
const camMaterial = new MeshNormalMaterial();
const camBody = new Mesh(camBodyGeometry, camMaterial);
const camLens = new Mesh(camLensGeometry, camMaterial);
camBody.position.set(0,0,0);
camLens.rotateZ(Math.PI / 2);
camLens.position.set(camSize*0.8,0,0);
this.refFrameCues.push(camBody)
this.refFrameCues.push(camLens)
const controls = new TrackballControls(
camera,
renderer.domElement
);
controls.rotateSpeed = 1.0;
controls.zoomSpeed = 1.2;
controls.panSpeed = 0.8;
controls.noZoom = false;
controls.noPan = false;
controls.staticMoving = true;
controls.dynamicDampingFactor = 0.3;
controls.keys = [65, 83, 68];
this.controls = controls;
this.scene.add(...this.refFrameCues)
this.resetCamFirstPerson();
controls.update();
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
//camera.updateMatrixWorld();
//console.log("================")
//console.log(camera.position);
//console.log(camera.rotation);
//console.log(camera.up);
}
this.drawTargets()
animate();
},
methods: {
drawTargets() {
this.scene.remove(...this.cubes)
this.cubes = []
for (const target of this.targets) {
const geometry = new BoxGeometry(0.3 / 5, 0.2, 0.2);
const material = new MeshNormalMaterial();
let quat = (new Quaternion(
target.pose.qx,
target.pose.qy,
target.pose.qz,
target.pose.qw,
))
const cube = new Mesh(geometry, material);
cube.position.set(target.pose.x, target.pose.y, target.pose.z)
cube.rotation.setFromQuaternion(quat);
this.cubes.push(cube)
let arrow = (new ArrowHelper(new Vector3(1, 0, 0).normalize(), new Vector3(0, 0, 0),
1, // length
0xff0000,
0.1,
0.1,
));
arrow.rotation.setFromQuaternion(quat)
arrow.rotateZ(-Math.PI / 2)
arrow.position.set(target.pose.x, target.pose.y, target.pose.z)
this.cubes.push(arrow);
arrow = (new ArrowHelper(new Vector3(1, 0, 0).normalize(), new Vector3(0, 0, 0),
1, // length
0x00ff00,
0.1,
0.1,
));
arrow.rotation.setFromQuaternion(quat)
// arrow.rotateX(Math.PI / 2)
arrow.position.set(target.pose.x, target.pose.y, target.pose.z)
this.cubes.push(arrow);
arrow = (new ArrowHelper(new Vector3(1, 0, 0).normalize(), new Vector3(0, 0, 0),
1, // length
0x0000ff,
0.1,
0.1,
));
arrow.setRotationFromQuaternion(quat)
arrow.rotateX(Math.PI / 2)
arrow.position.set(target.pose.x, target.pose.y, target.pose.z)
this.cubes.push(arrow);
}
if(this.cubes.length > 0)
this.scene.add(...this.cubes);
},
onWindowResize() {
const container = document.getElementById("MapContainer");
if(container){
this.canvas.width = container.clientWidth * 0.95;
this.canvas.height = container.clientWidth * 0.85;
this.camera.aspect = this.canvas.width / this.canvas.height;
this.camera.updateProjectionMatrix();
this.renderer.setSize( this.canvas.width, this.canvas.height );
}
},
resetCamThirdPerson(){
//Sets camera to third person position
this.controls.reset();
this.camera.position.set(-1.39,-1.09,1.17);
this.camera.up.set(0,0,1);
this.controls.target.set(4.0,0.0,0.0);
this.controls.update();
this.scene.add(...this.refFrameCues)
},
resetCamFirstPerson(){
//Sets camera to first person position
this.controls.reset();
this.camera.position.set(-0.1,0,0);
this.camera.up.set(0,0,1);
this.controls.target.set(0.0,0.0,0.0);
this.controls.update();
this.scene.remove(...this.refFrameCues)
},
}
}
</script>
<style scoped>
</style>

View File

@@ -1,440 +0,0 @@
<template>
<div>
<v-snackbar
v-model="snack"
top
:color="snackbar.color"
:timeout="2000"
>
<span>{{ snackbar.text }}</span>
</v-snackbar>
<v-row
align="center"
style="padding: 12px 12px 12px 24px"
>
<v-col
cols="10"
md="5"
lg="10"
no-gutters
class="pa-0"
>
<CVselect
v-if="isCameraNameEdit === false"
v-model="currentCameraIndex"
name="Camera"
:list="$store.getters.cameraList"
@input="handleInput('currentCamera',currentCameraIndex)"
/>
<CVinput
v-else
v-model="newCameraName"
name="Camera"
input-cols="9"
:error-message="checkCameraName"
@Enter="saveCameraNameChange"
/>
</v-col>
<v-col
cols="2"
md="1"
lg="2"
class="pl-5"
>
<CVicon
v-if="isCameraNameEdit === false"
color="#c5c5c5"
:hover="true"
text="mdi-pencil"
tooltip="Edit camera name"
@click="changeCameraName"
/>
<div v-else>
<CVicon
color="#c5c5c5"
style="display: inline-block;"
:hover="true"
text="mdi-content-save"
tooltip="Save Camera Name"
@click="saveCameraNameChange"
/>
<CVicon
color="error"
style="display: inline-block;"
:hover="true"
text="mdi-close"
tooltip="Discard Changes"
@click="discardCameraNameChange"
/>
</div>
</v-col>
<v-col
cols="10"
md="5"
lg="10"
no-gutters
class="pa-0"
>
<CVselect
v-model="currentPipelineIndex"
name="Pipeline"
tooltip="Each pipeline runs on a camera output and stores a unique set of processing settings"
:disabled="$store.getters.isDriverMode"
:list="($store.getters.isDriverMode ? ['Driver Mode'] : []).concat($store.getters.pipelineList)"
@input="handleInputWithIndex('currentPipeline', currentPipelineIndex)"
/>
</v-col>
<v-col
cols="2"
md="1"
lg="2"
class="pl-5"
>
<v-menu
v-if="!$store.getters.isDriverMode"
offset-y
auto
>
<template v-slot:activator="{ on }">
<v-icon
color="#c5c5c5"
v-on="on"
>
mdi-menu
</v-icon>
</template>
<v-list
dark
dense
color="primary"
>
<v-list-item @click="toPipelineNameChange">
<v-list-item-title>
<CVicon
color="#c5c5c5"
:right="true"
text="mdi-pencil"
tooltip="Edit pipeline name"
/>
</v-list-item-title>
</v-list-item>
<v-list-item @click="toCreatePipeline">
<v-list-item-title>
<CVicon
color="#c5c5c5"
:right="true"
text="mdi-plus"
tooltip="Add new pipeline"
/>
</v-list-item-title>
</v-list-item>
<v-list-item @click="deleteCurrentPipeline">
<v-list-item-title>
<CVicon
color="red darken-2"
:right="true"
text="mdi-delete"
tooltip="Delete pipeline"
/>
</v-list-item-title>
</v-list-item>
<v-list-item @click="duplicatePipeline">
<v-list-item-title>
<CVicon
color="#c5c5c5"
:right="true"
text="mdi-content-copy"
tooltip="Duplicate pipeline"
/>
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-col>
<v-col
v-if="_currentPipelineType >= 0"
cols="10"
md="11"
lg="10"
no-gutters
class="pa-0"
>
<CVselect
v-model="_currentPipelineType"
name="Type"
tooltip="Changes the pipeline type, which changes the type of processing that will happen on input frames"
:list="['Reflective Tape', 'Colored Shape', 'AprilTag']"
@input="e => showTypeDialog(e)"
/>
</v-col>
</v-row>
<!--pipeline naming dialog-->
<v-dialog
v-model="namingDialog"
dark
persistent
width="500"
height="357"
>
<v-card
dark
color="primary"
>
<v-card-title
class="headline"
primary-title
>
{{ isPipelineNameEdit ? "Edit Pipeline Name" : "Create Pipeline" }}
</v-card-title>
<v-card-text>
<CVinput
v-model="newPipelineName"
name="Name"
:error-message="checkPipelineName"
/>
</v-card-text>
<v-divider />
<v-card-actions>
<v-spacer />
<v-btn
color="#ffd843"
:disabled="checkPipelineName !==''"
@click="savePipelineNameChange"
>
Save
</v-btn>
<v-btn
color="error"
@click="discardPipelineNameChange"
>
Cancel
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog
v-model="showPipeTypeDialog"
width="600"
>
<v-card
color="primary"
dark
>
<v-card-title>Change Pipeline Type</v-card-title>
<v-card-text>
Changing the type of this pipeline will erase the current pipeline's settings and replace it with a new {{ ['Reflective', 'Shape'][proposedPipelineType] }} pipeline. <b class="red--text format_bold">You will lose all settings for the pipeline
"{{ ($store.getters.isDriverMode ? ['Driver Mode'] : []).concat($store.getters.pipelineList)[currentPipelineIndex] }}."</b> Are you sure you want to do this?
<v-row
class="mt-6"
style="display: flex; align-items: center; justify-content: center"
align="center"
>
<v-btn
class="mr-3"
color="red"
width="250"
@click="e => changePipeType(true)"
>
Yes, replace this pipeline
</v-btn>
<v-btn
class="ml-10"
color="secondary"
width="250"
@click="e => changePipeType(false)"
>
No, take me back
</v-btn>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
</div>
</template>
<script>
import CVicon from '../common/cv-icon'
import CVselect from '../common/cv-select'
import CVinput from '../common/cv-input'
export default {
name: "CameraAndPipelineSelect",
components: {
CVicon,
CVselect,
CVinput
},
data: () => {
return {
re: RegExp("^[A-Za-z0-9_ \\-)(]*[A-Za-z0-9][A-Za-z0-9_ \\-)(.]*$"),
isCameraNameEdit: false,
newCameraName: "",
cameraNameError: "",
isPipelineNameEdit: false,
namingDialog: false,
newPipelineName: "",
duplicateDialog: false,
showPipeTypeDialog: false,
proposedPipelineType : 0,
pipeIndexToDuplicate: undefined,
snack: false,
snackbar: {
color: "success",
text: "",
}
}
},
computed: {
checkCameraName() {
if (this.newCameraName !== this.$store.getters.cameraList[this.currentCameraIndex]) {
if (this.re.test(this.newCameraName)) {
for (let cam in this.cameraList) {
if (this.cameraList.hasOwnProperty(cam)) {
if (this.newCameraName === this.cameraList[cam]) {
return "A camera by that name already exists"
}
}
}
} else {
return "A camera name can only contain letters, numbers, and spaces"
}
}
return "";
},
checkPipelineName() {
if (this.newPipelineName !== this.$store.getters.pipelineList[this.currentPipelineIndex - 1] || !this.isPipelineNameEdit) {
if (this.re.test(this.newPipelineName)) {
for (let pipe in this.$store.getters.pipelineList) {
if (this.$store.getters.pipelineList.hasOwnProperty(pipe)) {
if (this.newPipelineName === this.$store.getters.pipelineList[pipe]) {
return "A pipeline with this name already exists"
}
}
}
} else {
return "A pipeline name can only contain letters, numbers, and spaces"
}
}
return ""
},
currentCameraIndex: {
get() {
return this.$store.getters.currentCameraIndex;
},
set(value) {
this.$store.commit('currentCameraIndex', value);
}
},
currentPipelineIndex: {
get() {
return this.$store.getters.currentPipelineIndex + (this.$store.getters.isDriverMode ? 1 : 0);
},
set(value) {
this.$store.commit('currentPipelineIndex', value - (this.$store.getters.isDriverMode ? 1 : 0));
}
},
_currentPipelineType: {
get() {
return this.$store.getters.currentPipelineSettings.pipelineType - 2;
},
set(value) {
value; // nop, since we have the dialog for this
}
}
},
methods: {
showTypeDialog(idx) {
// Only show the dialog if it's a new type
this.showPipeTypeDialog = idx !== this._currentPipelineType;
this.proposedPipelineType = idx;
},
changePipeType(actuallyChange) {
const newIdx = actuallyChange ? this.proposedPipelineType : this._currentPipelineType
this.handleInputWithIndex('pipelineType', newIdx);
this.showPipeTypeDialog = false;
},
changeCameraName() {
this.newCameraName = this.$store.getters.cameraList[this.currentCameraIndex];
this.isCameraNameEdit = true;
},
saveCameraNameChange() {
if (this.checkCameraName === "") {
this.axios.post('http://' + this.$address + '/api/settings/camera/setNickname',
{name: this.newCameraName, cameraIndex: this.$store.getters.currentCameraIndex})
.then(response => {
this.$emit('camera-name-changed')
this.snackbar = {
color: "success",
text: response.data.text || response.data
}
this.snack = true;
})
.catch(error => {
this.$emit('camera-name-changed')
if(error.response) {
this.snackbar = {
color: "error",
text: error.response.data.text || error.response.data
}
} else if(error.request) {
this.snackbar = {
color: "error",
text: "Error while trying to process the request! The backend didn't respond.",
};
} else {
this.snackbar = {
color: "error",
text: "An error occurred while trying to process the request.",
};
}
this.snack = true;
})
this.discardCameraNameChange();
}
},
discardCameraNameChange() {
this.isCameraNameEdit = false;
this.newCameraName = "";
},
toPipelineNameChange() {
this.newPipelineName = this.$store.getters.pipelineList[this.currentPipelineIndex - 1];
this.isPipelineNameEdit = true;
this.namingDialog = true;
},
toCreatePipeline() {
this.newPipelineName = "New Pipeline";
this.isPipelineNameEdit = false;
this.namingDialog = true;
},
deleteCurrentPipeline() {
if (this.$store.getters.pipelineList.length > 1) {
this.handleInputWithIndex('deleteCurrentPipeline', {});
} else {
this.snackbar = true;
}
},
savePipelineNameChange() {
if (this.checkPipelineName === "") {
if (this.isPipelineNameEdit) {
this.handleInputWithIndex("changePipelineName", this.newPipelineName);
} else {
this.handleInputWithIndex("addNewPipeline", [this.newPipelineName, this._currentPipelineType]); // 0 for reflective, 1 for colored shape
}
this.discardPipelineNameChange();
}
},
duplicatePipeline() {
this.handleInputWithIndex("duplicatePipeline", this.currentPipelineIndex);
},
discardPipelineNameChange() {
this.namingDialog = false;
this.isPipelineNameEdit = false;
this.newPipelineName = "";
},
}
}
</script>

View File

@@ -1,63 +0,0 @@
<template>
<div>
<v-row
align="center"
justify="start"
>
<v-col cols="4">
<v-btn
small
color="accent"
style="width: 100%;"
class="black--text"
@click="takePointA"
>
Take Point A
</v-btn>
</v-col>
<v-col cols="4">
<v-btn
small
color="accent"
style="width: 100%;"
class="black--text"
@click="takePointB"
>
Take Point B
</v-btn>
</v-col>
<v-col cols="4">
<v-btn
small
color="yellow darken-3"
style="width: 100%;"
@click="clearPoints"
>
Clear All Points
</v-btn>
</v-col>
</v-row>
</div>
</template>
<script>
export default {
name: "DualCalibration",
methods: {
clearPoints() {
this.handleInputWithIndex("robotOffsetPoint", 0, this.$store.state.currentCameraIndex)
},
takePointA() {
this.handleInputWithIndex("robotOffsetPoint", 2, this.$store.state.currentCameraIndex)
},
takePointB() {
this.handleInputWithIndex("robotOffsetPoint", 3, this.$store.state.currentCameraIndex)
}
}
}
</script>
<style scoped>
</style>

View File

@@ -1,48 +0,0 @@
<template>
<div>
<v-row
align="center"
justify="start"
>
<v-col cols="6">
<v-btn
small
color="accent"
class="black--text"
style="width: 100%;"
@click="takePoint"
>
Take Point
</v-btn>
</v-col>
<v-col cols="6">
<v-btn
small
color="yellow darken-3"
style="width: 100%;"
@click="clearPoint"
>
Clear Point
</v-btn>
</v-col>
</v-row>
</div>
</template>
<script>
export default {
name: "SingleCalibration",
methods: {
clearPoint() {
this.handleInputWithIndex("robotOffsetPoint", 0, this.$store.state.currentCameraIndex)
},
takePoint() {
this.handleInputWithIndex("robotOffsetPoint", 1, this.$store.state.currentCameraIndex)
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,393 @@
<script setup lang="ts">
import { inject, ref } from "vue";
import { useStateStore } from "@/stores/StateStore";
import CvSelect from "@/components/common/cv-select.vue";
import axios from "axios";
const restartProgram = () => {
axios.post("/utils/restartProgram")
.then(() => {
useStateStore().showSnackbarMessage({
message: "Successfully sent program restart request",
color: "success"
});
})
.catch(error => {
// This endpoint always return 204 regardless of outcome
if (error.request) {
useStateStore().showSnackbarMessage({
message: "Error while trying to process the request! The backend didn't respond.",
color: "error"
});
} else {
useStateStore().showSnackbarMessage({
message: "An error occurred while trying to process the request.",
color: "error"
});
}
});
};
const restartDevice = () => {
axios.post("/utils/restartDevice")
.then(() => {
useStateStore().showSnackbarMessage({
message: "Successfully dispatched the restart command. It isn't confirmed if a device restart will occur.",
color: "success"
});
})
.catch(error => {
if (error.response) {
useStateStore().showSnackbarMessage({
message: "The backend is unable to fulfil the request to restart the device.",
color: "error"
});
} else if (error.request) {
useStateStore().showSnackbarMessage({
message: "Error while trying to process the request! The backend didn't respond.",
color: "error"
});
} else {
useStateStore().showSnackbarMessage({
message: "An error occurred while trying to process the request.",
color: "error"
});
}
});
};
const address = inject<string>("backendHost");
const offlineUpdate = ref();
const openOfflineUpdatePrompt = () => {
offlineUpdate.value.click();
};
const handleOfflineUpdate = ({ files } : { files: FileList}) => {
useStateStore().showSnackbarMessage({ message: "New Software Upload in Progress...", color: "secondary", timeout: -1 });
const formData = new FormData();
formData.append("jarData", files[0]);
axios.post("/utils/offlineUpdate", formData, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: ({ progress }) => {
const uploadPercentage = ((progress || 0) * 100.0);
if (uploadPercentage < 99.5) {
useStateStore().showSnackbarMessage({
message: "New Software Upload in Process, " + uploadPercentage.toFixed(2) + "% complete",
color: "secondary",
timeout: -1
});
} else {
useStateStore().showSnackbarMessage({
message: "Installing uploaded software...",
color: "secondary",
timeout: -1
});
}
}
})
.then(response => {
useStateStore().showSnackbarMessage({
message: response.data.text || response.data,
color: "success"
});
})
.catch(error => {
if (error.response) {
useStateStore().showSnackbarMessage({
color: "error",
message: error.response.data.text || error.response.data
});
} else if (error.request) {
useStateStore().showSnackbarMessage({
color: "error",
message: "Error while trying to process the request! The backend didn't respond."
});
} else {
useStateStore().showSnackbarMessage({
color: "error",
message: "An error occurred while trying to process the request."
});
}
});
};
const exportLogFile = ref();
const openExportLogsPrompt = () => {
exportLogFile.value.click();
};
const exportSettings = ref();
const openExportSettingsPrompt = () => {
exportSettings.value.click();
};
enum ImportType {
AllSettings,
HardwareConfig,
HardwareSettings,
NetworkConfig
}
const showImportDialog = ref(false);
const importType = ref<ImportType | number>(-1);
const importFile = ref(null);
const handleSettingsImport = () => {
if (importType.value === -1 || importFile.value === null) return;
const formData = new FormData();
formData.append("data", importFile.value);
let settingsEndpoint;
switch (importType.value) {
case ImportType.AllSettings:
settingsEndpoint = "";
break;
case ImportType.HardwareConfig:
settingsEndpoint = "/hardwareConfig";
break;
case ImportType.HardwareSettings:
settingsEndpoint = "/hardwareSettings";
break;
case ImportType.NetworkConfig:
settingsEndpoint = "/networkConfig";
break;
}
axios.post(`/settings${settingsEndpoint}`, formData, {
headers: { "Content-Type": "multipart/form-data" }
})
.then(response => {
useStateStore().showSnackbarMessage({
message: response.data.text || response.data,
color: "success"
});
})
.catch(error => {
if (error.response) {
useStateStore().showSnackbarMessage({
color: "error",
message: error.response.data.text || error.response.data
});
} else if (error.request) {
useStateStore().showSnackbarMessage({
color: "error",
message: "Error while trying to process the request! The backend didn't respond."
});
} else {
useStateStore().showSnackbarMessage({
color: "error",
message: "An error occurred while trying to process the request."
});
}
});
showImportDialog.value = false;
importType.value = -1;
importFile.value = null;
};
</script>
<template>
<v-card
dark
class="mb-3 pr-6 pb-3"
style="background-color: #006492;"
>
<v-card-title>Device Control</v-card-title>
<div class="ml-5">
<v-row>
<v-col
cols="12"
lg="4"
md="6"
>
<v-btn
color="red"
@click="restartProgram"
>
<v-icon left>
mdi-restart
</v-icon>
Restart PhotonVision
</v-btn>
</v-col>
<v-col
cols="12"
lg="4"
md="6"
>
<v-btn
color="red"
@click="restartDevice"
>
<v-icon left>
mdi-restart-alert
</v-icon>
Restart Device
</v-btn>
</v-col>
<v-col
cols="12"
lg="4"
>
<v-btn
color="secondary"
@click="openOfflineUpdatePrompt"
>
<v-icon left>
mdi-upload
</v-icon>
Offline Update
</v-btn>
<input
ref="offlineUpdate"
type="file"
accept=".jar"
style="display: none;"
@change="handleOfflineUpdate"
>
</v-col>
</v-row>
<v-divider style="margin: 12px 0;" />
<v-row>
<v-col
cols="12"
sm="6"
>
<v-btn
color="secondary"
@click="() => showImportDialog = true"
>
<v-icon left>
mdi-import
</v-icon>
Import Settings
</v-btn>
<v-dialog
v-model="showImportDialog"
width="600"
@input="() => {
importType = -1;
importFile = null;
}"
>
<v-card
color="primary"
dark
>
<v-card-title>Import Settings</v-card-title>
<v-card-text>
Upload and apply previously saved or exported PhotonVision settings to this device
<v-row
class="mt-6 ml-4"
>
<cv-select
v-model="importType"
label="Type"
tooltip="Select the type of settings file you are trying to upload"
:items="['All Settings', 'Hardware Config', 'Hardware Settings', 'Network Config']"
:select-cols="10"
/>
</v-row>
<v-row
class="mt-6 ml-4 mr-8"
>
<v-file-input
:disabled="importType === -1"
:error-messages="importType === -1 ? 'Settings type not selected' : ''"
:accept="importType === ImportType.AllSettings ? '.zip' : '.json'"
@change="(file) => importFile = file"
/>
</v-row>
<v-row
class="mt-12 ml-8 mr-8 mb-1"
style="display: flex; align-items: center; justify-content: center"
align="center"
>
<v-btn
color="secondary"
:disabled="importFile === null"
@click="handleSettingsImport"
>
<v-icon left>
mdi-import
</v-icon>
Import Settings
</v-btn>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
</v-col>
<v-col
cols="12"
sm="6"
>
<v-btn
color="secondary"
@click="openExportSettingsPrompt"
>
<v-icon left>
mdi-export
</v-icon>
Export Settings
</v-btn>
<a
ref="exportSettings"
style="color: black; text-decoration: none; display: none"
:href="`http://${address}/api/settings/photonvision_config.zip`"
download="photonvision-settings.zip"
target="_blank"
/>
</v-col>
<v-col
cols="12"
sm="6"
>
<v-btn
color="secondary"
@click="openExportLogsPrompt"
>
<v-icon left>
mdi-download
</v-icon>
Download Current Log
<!-- Special hidden link that gets 'clicked' when the user exports journalctl logs -->
<a
ref="exportLogFile"
style="color: black; text-decoration: none; display: none"
:href="'http://' + address + '/api/utils/logs/photonvision-journalctl.txt'"
download="photonvision-journalctl.txt"
target="_blank"
/>
</v-btn>
</v-col>
<v-col
cols="12"
sm="6"
>
<v-btn
color="secondary"
@click="useStateStore().showLogModal = true"
>
<v-icon left>
mdi-eye
</v-icon>
Show log viewer
</v-btn>
</v-col>
</v-row>
</div>
</v-card>
</template>
<style scoped>
.v-divider {
border-color: white !important;
}
.v-btn {
width: 100%;
}
</style>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import CvSlider from "@/components/common/cv-slider.vue";
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
</script>
<template>
<v-card
dark
class="mb-3 pr-6 pb-3"
style="background-color: #006492;"
>
<v-card-title>LED Control</v-card-title>
<div class="ml-5">
<cv-slider
v-model="useSettingsStore().lighting.brightness"
label="Brightness"
class="pt-2"
:slider-cols="12"
:min="0"
:max="100"
@input="args => useSettingsStore().changeLEDBrightness(args)"
/>
</div>
</v-card>
</template>

View File

@@ -0,0 +1,232 @@
<script setup lang="ts">
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
import { computed, onBeforeMount, ref } from "vue";
import { useStateStore } from "@/stores/StateStore";
import CvIcon from "@/components/common/cv-icon.vue";
interface MetricItem {
header: string,
value?: string
}
const generalMetrics = computed<MetricItem[]>(() => [
{
header: "Version",
value: useSettingsStore().general.version || "Unknown"
},
{
header: "Hardware Model",
value: useSettingsStore().general.hardwareModel || "Unknown"
},
{
header: "Platform",
value: useSettingsStore().general.hardwarePlatform || "Unknown"
},
{
header: "GPU Acceleration",
value: useSettingsStore().general.gpuAcceleration || "Unknown"
}]);
const platformMetrics = computed<MetricItem[]>(() => [
{
header: "CPU Temp",
value: useSettingsStore().metrics.cpuTemp === undefined ? "Unknown" : `${useSettingsStore().metrics.cpuTemp}°C`
},
{
header: "CPU Usage",
value: useSettingsStore().metrics.cpuUtil === undefined ? "Unknown" :`${useSettingsStore().metrics.cpuUtil}%`
},
{
header: "CPU Memory Usage",
value: useSettingsStore().metrics.ramUtil === undefined || useSettingsStore().metrics.cpuMem === undefined ? "Unknown" : `${useSettingsStore().metrics.ramUtil || "Unknown"}MB of ${useSettingsStore().metrics.cpuMem}MB`
},
{
header: "GPU Memory Usage",
value: useSettingsStore().metrics.gpuMemUtil === undefined || useSettingsStore().metrics.gpuMem === undefined ? "Unknown" : `${useSettingsStore().metrics.gpuMemUtil}MB of ${useSettingsStore().metrics.gpuMem}MB`
},
{
header: "CPU Throttling",
value: useSettingsStore().metrics.cpuThr || "Unknown"
},
{
header: "CPU Uptime",
value: useSettingsStore().metrics.cpuUptime || "Unknown"
},
{
header: "Disk Usage",
value: useSettingsStore().metrics.diskUtilPct || "Unknown"
}
]);
const metricsLastFetched = ref("Never");
const fetchMetrics = () => {
useSettingsStore()
.requestMetricsUpdate()
.catch(error => {
if(error.request) {
useStateStore().showSnackbarMessage({
color: "error",
message: "Unable to fetch Metrics! The backend didn't respond."
});
} else {
useStateStore().showSnackbarMessage({
color: "error",
message: "An error occurred while trying to fetch Metrics."
});
}
})
.finally(() => {
const pad = (num: number): string => {
return String(num).padStart(2, "0");
};
const date = new Date();
metricsLastFetched.value = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
});
};
onBeforeMount(() => {
fetchMetrics();
});
</script>
<template>
<v-card
dark
class="mb-3 pr-6 pb-3"
style="background-color: #006492;"
>
<v-card-title style="display: flex; justify-content: space-between">
<span>Stats</span>
<cv-icon
icon-name="mdi-reload"
color="white"
tooltip="Reload Metrics"
hover
@click="fetchMetrics"
/>
</v-card-title>
<v-row class="pt-2 pa-4 ma-0 ml-5 pb-1">
<v-card-subtitle
class="ma-0 pa-0 pb-2"
style="font-size: 16px"
>
General Metrics
</v-card-subtitle>
<v-simple-table class="metrics-table">
<thead>
<tr>
<th
v-for="(item, itemIndex) in generalMetrics"
:key="itemIndex"
class="metric-item metric-item-title"
>
{{ item.header }}
</th>
</tr>
</thead>
<tbody>
<tr>
<td
v-for="(item, itemIndex) in generalMetrics"
:key="itemIndex"
class="metric-item"
>
{{ item.value }}
</td>
</tr>
</tbody>
</v-simple-table>
</v-row>
<v-row class="pa-4 ma-0 ml-5">
<v-card-subtitle
class="ma-0 pa-0 pb-2"
style="font-size: 16px"
>
Hardware Metrics
</v-card-subtitle>
<v-simple-table class="metrics-table">
<thead>
<tr>
<th
v-for="(item, itemIndex) in platformMetrics"
:key="itemIndex"
class="metric-item metric-item-title"
>
{{ item.header }}
</th>
</tr>
</thead>
<tbody>
<tr>
<td
v-for="(item, itemIndex) in platformMetrics"
:key="itemIndex"
class="metric-item"
>
<span v-if="useSettingsStore().metrics.cpuUtil !== undefined">{{ item.value }}</span>
<span v-else>---</span>
</td>
</tr>
</tbody>
</v-simple-table>
</v-row>
<div style="text-align: right">
<span>Last Fetched: {{ metricsLastFetched }}</span>
</div>
</v-card>
</template>
<style scoped lang="scss">
.metrics-table{
border-collapse: separate;
border-spacing: 0;
border-radius: 5px;
margin-bottom: 10px;
border: 1px solid white;
width: 100%;
text-align: center;
}
.metric-item {
font-size: 16px !important;
padding: 1px 15px 1px 10px;
border-right: 1px solid;
font-weight: normal;
color: white !important;
text-align: center !important;
}
.metric-item-title {
font-size: 18px !important;
text-decoration: underline;
text-decoration-color: #ffd843;
}
.v-data-table {
thead, tbody {
background-color: #006492;
}
:hover {
tbody > tr {
background-color: #005281 !important;
}
}
::-webkit-scrollbar {
width: 0;
height: 0.55em;
border-radius: 5px;
}
::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background-color: #ffd843;
border-radius: 10px;
}
}
</style>

View File

@@ -0,0 +1,164 @@
<script setup lang="ts">
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
import { ref } from "vue";
import CvInput from "@/components/common/cv-input.vue";
import CvRadio from "@/components/common/cv-radio.vue";
import CvSwitch from "@/components/common/cv-switch.vue";
import { NetworkConnectionType } from "@/types/SettingTypes";
import { useStateStore } from "@/stores/StateStore";
const settingsValid = ref(true);
const isValidNetworkTablesIP = (v: string | undefined): boolean => {
// Check if it is a valid team number between 1-9999
const teamNumberRegex = /^[1-9][0-9]{0,3}$/;
// Check if it is a team number longer than 5 digits
const badTeamNumberRegex = /^[0-9]{5,}$/;
if(v === undefined) return false;
if (teamNumberRegex.test(v)) return true;
if (isValidIPv4(v)) return true;
// need to check these before the hostname. "0" and "99999" are valid hostnames, but we don't want to allow then
if (v === "0") return false;
if (badTeamNumberRegex.test(v)) return false;
return isValidHostname(v);
};
const isValidIPv4 = (v: string | undefined) => {
// https://stackoverflow.com/a/17871737
const ipv4Regex = /^((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])$/;
if(v === undefined) return false;
return ipv4Regex.test(v);
};
const isValidHostname = (v: string | undefined) => {
// https://stackoverflow.com/a/18494710
const hostnameRegex = /^([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)+(\.([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*))*$/;
if(v === undefined) return false;
return hostnameRegex.test(v);
};
const saveGeneralSettings = () => {
const changingStaticIp = useSettingsStore().network.connectionType === NetworkConnectionType.Static;
useSettingsStore().saveGeneralSettings()
.then(response => {
useStateStore().showSnackbarMessage({
message: response.data.text || response.data,
color: "success"
});
})
.catch(error => {
if(error.response) {
if (error.status === 504 || changingStaticIp) {
useStateStore().showSnackbarMessage({
color: "error",
message: `Connection lost! Try the new static IP at ${useSettingsStore().network.staticIp}:5800 or ${useSettingsStore().network.hostname}:5800?`
});
} else {
useStateStore().showSnackbarMessage({
color: "error",
message: error.response.data.text || error.response.data
});
}
} else if(error.request) {
useStateStore().showSnackbarMessage({
color: "error",
message: "Error while trying to process the request! The backend didn't respond."
});
} else {
useStateStore().showSnackbarMessage({
color: "error",
message: "An error occurred while trying to process the request."
});
}
});
};
</script>
<template>
<v-card
dark
class="mb-3 pr-6 pb-3"
style="background-color: #006492;"
>
<v-card-title>Networking</v-card-title>
<div class="ml-5">
<v-form
ref="form"
v-model="settingsValid"
>
<cv-input
v-model="useSettingsStore().network.ntServerAddress"
label="Team Number/NetworkTables Server Address"
tooltip="Enter the Team Number or the IP address of the NetworkTables Server"
:label-cols="3"
:disabled="useSettingsStore().network.runNTServer"
:rules="[v => isValidNetworkTablesIP(v) || 'The NetworkTables Server Address must be a valid Team Number, IP address, or Hostname']"
/>
<v-banner
v-show="!isValidNetworkTablesIP(useSettingsStore().network.ntServerAddress) && !useSettingsStore().network.runNTServer"
rounded
color="red"
text-color="white"
style="margin: 10px 0"
icon="mdi-alert-circle-outline"
>
The NetworkTables Server Address is not set or is invalid. NetworkTables is unable to connect.
</v-banner>
<cv-radio
v-show="useSettingsStore().network.shouldMange"
v-model="useSettingsStore().network.connectionType"
label="IP Assignment Mode"
tooltip="DHCP will make the radio (router) automatically assign an IP address; this may result in an IP address that changes across reboots. Static IP assignment means that you pick the IP address and it won't change."
:input-cols="12-3"
:list="['DHCP','Static']"
/>
<cv-input
v-if="useSettingsStore().network.connectionType === NetworkConnectionType.Static"
v-model="useSettingsStore().network.staticIp"
:input-cols="12-3"
label="Static IP"
:rules="[v => isValidIPv4(v) || 'Invalid IPv4 address']"
/>
<cv-input
v-show="useSettingsStore().network.shouldMange"
v-model="useSettingsStore().network.hostname"
label="Hostname"
:input-cols="12-3"
:rules="[v => isValidHostname(v) || 'Invalid hostname']"
/>
<cv-switch
v-model="useSettingsStore().network.runNTServer"
label="Run NetworkTables Server (Debugging Only)"
tooltip="If enabled, this device will create a NT server. This is useful for home debugging, but should be disabled on-robot."
class="mt-3 mb-3"
:label-cols="3"
/>
<v-banner
v-show="useSettingsStore().network.runNTServer"
rounded
color="red"
text-color="white"
icon="mdi-information-outline"
>
This mode is intended for debugging; it should be off for proper usage. PhotonLib will NOT work!
</v-banner>
</v-form>
<v-btn
color="accent"
:class="useSettingsStore().network.runNTServer ? 'mt-3' : ''"
style="color: black; width: 100%;"
:disabled="!settingsValid && !useSettingsStore().network.runNTServer"
@click="saveGeneralSettings"
>
Save
</v-btn>
</div>
</v-card>
</template>
<style>
.v-banner__wrapper {
padding: 6px !important;
}
</style>

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +0,0 @@
# JSPDF Fonts
These are .js interpretations of the .tff files in the branding folder. They are used by jspdf to apply branding-appropriate fonts to any .pdf file generation (ex: calibration targets)
https://peckconsulting.s3.amazonaws.com/fontconverter/fontconverter.html is the converter used to generate them.
https://www.devlinpeck.com/tutorials/jspdf-custom-font has more info creating/using them.

View File

@@ -0,0 +1,92 @@
import { decode, encode } from "@msgpack/msgpack";
import type { IncomingWebsocketData } from "@/types/WebsocketDataTypes";
/**
* {@link WebSocket} wrapper class that automatically reconnects to the provided host address if the connection was closed by the remote host or a connection failure.
* Data sent and received by the Websocket is automatically encoded and decoded using msgpack.
*/
export class AutoReconnectingWebsocket {
private readonly serverAddress: string | URL;
private websocket: WebSocket | null | undefined;
private readonly onConnect: () => void;
private readonly onData: (data: IncomingWebsocketData) => void;
private readonly onDisconnect: () => void;
/**
* Create an AutoReconnectingWebsocket
*
* @param serverAddress address of the websocket
* @param onConnect action to run on websocket connection (when the websocket changes to the OPEN state)
* @param onData decoded websocket message data consumer. The data is automatically decoded by msgpack.
* @param onDisconnect action to run on websocket disconnection (when the websocket changes to the CLOSED state)
*/
constructor(serverAddress: string | URL, onConnect: () => void, onData: (data: IncomingWebsocketData) => void, onDisconnect: () => void) {
this.serverAddress = serverAddress;
this.onConnect = onConnect;
this.onData = onData;
this.onDisconnect = onDisconnect;
this.initializeWebsocket();
}
/**
* Send data over the websocket. This is a no-op if the websocket is not in the OPEN state.
*
* @param data data to send
* @param encodeData whether or not to encode the data using msgpack (defaults to true)
* @see isConnected
*
*/
send(data, encodeData = true) {
// Only send data if the websocket is open
if(this.isConnected()) {
if(encodeData) {
this.websocket?.send(encode(data));
} else {
this.websocket?.send(data);
}
}
}
/**
* Check if the WebSocket is OPEN and connected
*/
isConnected(): boolean {
return this.websocket === null || this.websocket === undefined
? false
: this.websocket.readyState === WebSocket.OPEN;
}
/**
* Handles the creation of the websocket and the binding of the action consumers.
*
* @private
*/
private initializeWebsocket() {
this.websocket = new WebSocket(this.serverAddress);
this.websocket.binaryType = "arraybuffer";
this.websocket.onopen = () => {
console.debug("[WebSocket] Websocket Open");
this.onConnect();
};
this.websocket.onmessage = (event: MessageEvent) => {
this.onData(decode(event.data) as IncomingWebsocketData);
};
this.websocket.onclose = (event: CloseEvent) => {
this.onDisconnect();
this.websocket = null;
console.info("[WebSocket] The WebSocket was closed. Will reattempt in 500 milliseconds.", event.reason);
setTimeout(this.initializeWebsocket.bind(this), 500);
};
this.websocket.onerror = () => {
this.websocket?.close();
};
console.debug(`[WebSocket] Attempting to initialize Websocket connection to ${this.serverAddress}`);
}
}

View File

@@ -0,0 +1,98 @@
export type HSV = [number, number, number]
export type RGBA = [number, number, number, number] | Uint8ClampedArray
export class ColorPicker {
public hsvData: HSV;
constructor(pixelData: RGBA) {
this.hsvData = this.RGBtoHSV(pixelData);
}
public selectedColorRange() {
return this.widenRange([[...this.hsvData], [...this.hsvData]]);
}
public expandColorRange(currentRange: [HSV, HSV]) {
const widenedHSV = this.widenRange([[...this.hsvData], [...this.hsvData]]);
return this.createRange(currentRange.concat(widenedHSV));
}
public shrinkColorRange(currentRange: [HSV, HSV]) {
const widenedHSV = this.widenRange([[...this.hsvData], [...this.hsvData]]);
//Tries to shrink the lower part of to widened HSV
if(!this.shrinkRange(currentRange, widenedHSV[0])) {
//If the prev attempt failed, try to shrink the higher part of to widened HSV
this.shrinkRange(currentRange, widenedHSV[1]);
}
return currentRange;
}
private createRange(range: HSV[]): [HSV, HSV] {
const newRange: [HSV, HSV] = [[0, 0, 0], [0, 0, 0]];
for (let i = 0; i < 3; i++) {
newRange[0][i] = range[0][i];
newRange[1][i] = range[0][i];
for (let j = range.length - 1; j >= 0; j--) {
newRange[0][i] = Math.min(range[j][i], newRange[0][i]);
newRange[1][i] = Math.max(range[j][i], newRange[1][i]);
}
}
return newRange;
}
private widenRange(range: [HSV, HSV]): [HSV, HSV] {
const expanded: [HSV, HSV] = [[0, 0, 0], [0, 0, 0]];
for (let i = 0; i < 3; i++) {
//Expanding the range by 10
expanded[0][i] = Math.max(0, range[0][i] - 10);
expanded[1][i] = Math.min(255, range[1][i] + 10);
}
expanded[1][0] = Math.min(180, expanded[1][0]); //h is up to 180
return expanded;
}
private shrinkRange(range: [HSV, HSV], color: HSV): boolean {
for (let i = 0; i < color.length; i++) {
if (!(range[0][i] <= color[i] && color[i] <= range[1][i])) return false;
}
for (let i = 0; i < color.length; i++) {
if (color[i] - range[0][i] < range[1][i] - color[i]) {
//shrink from min side
range[0][i] = Math.min(range[0][i] + 10, range[1][i]);
} else {
//shrink from max side
range[1][i] = Math.max(range[1][i] - 10, range[0][i]);
}
}
return true;
}
private RGBtoHSV(rgba: RGBA ): HSV {
// Normalize RGB ranges
let r = rgba[0],
g = rgba[1],
b = rgba[2];
r = r / 255;
g = g / 255;
b = b / 255;
const minRGB = Math.min(r, Math.min(g, b));
const maxRGB = Math.max(r, Math.max(g, b));
const d = (r === minRGB) ? g - b : ((b === minRGB) ? r - g : b - r);
const h = (r === minRGB) ? 3 : ((b === minRGB) ? 1 : 5);
let H = 30 * (h - d / (maxRGB - minRGB));
let S = 255 * (maxRGB - minRGB) / maxRGB;
let V = 255 * maxRGB;
if (isNaN(H))
H = 0;
if (isNaN(S))
S = 0;
if (isNaN(V))
V = 0;
return [Math.round(H), Math.round(S), Math.round(V)];
}
}

View File

@@ -1,41 +0,0 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store/index'
import vuetify from './plugins/vuetify';
import msgPack from 'msgpack5';
import axios from 'axios';
import VueAxios from "vue-axios";
Vue.config.productionTip = false;
if (process.env.NODE_ENV === "production") {
Vue.prototype.$address = location.host;
} else if (process.env.NODE_ENV === "development") {
Vue.prototype.$address = location.hostname + ":5800";
}
// const wsDataURL = '//' + Vue.prototype.$address + '/websocket_data';
// import VueNativeSock from 'vue-native-websocket';
// Vue.use(VueNativeSock, wsDataURL, {
// reconnection: true,
// reconnectionDelay: 100,
// connectManually: true,
// format: "arraybuffer",
// });
Vue.use(VueAxios, axios);
Vue.prototype.$msgPack = msgPack(true);
import {dataHandleMixin} from './mixins/global/dataHandleMixin'
Vue.mixin(dataHandleMixin);
import {stateMixin} from './mixins/global/stateMixin'
Vue.mixin(stateMixin);
new Vue({
router,
store,
vuetify,
render: h => h(App)
}).$mount('#app');

43
photon-client/src/main.ts Normal file
View File

@@ -0,0 +1,43 @@
import Vue from "vue";
import App from "@/App.vue";
import { createPinia, PiniaVuePlugin } from "pinia";
import router from "@/router";
import vuetify from "@/plugins/vuetify";
import axios from "axios";
type PhotonClientRuntimeMode = "production" | "development" | "local-network-development";
const runtimeMode: PhotonClientRuntimeMode = process.env.NODE_ENV as PhotonClientRuntimeMode;
let backendHost: string;
let backendHostname: string;
switch (runtimeMode as PhotonClientRuntimeMode) {
case "development":
backendHost = `${location.hostname}:5800`;
backendHostname = location.hostname;
break;
case "local-network-development":
backendHost = "photonvision.local:5800";
backendHostname = "photonvision.local";
break;
case "production":
backendHost = location.host;
backendHostname = location.hostname;
break;
}
axios.defaults.baseURL = `http://${backendHost}/api`;
// Handle Plugins
Vue.use(PiniaVuePlugin);
new Vue({
router,
vuetify,
pinia: createPinia(),
provide: {
backendHost: backendHost,
backendHostname: backendHostname
},
render: (h) => h(App)
}).$mount("#app");

View File

@@ -1,53 +0,0 @@
export const dataHandleMixin = {
methods: {
handleInput(key, value) {
let msg = this.$msgPack.encode({[key]: value});
this.$store.state.websocket.ws.send(msg);
},
handleInputWithIndex(key, value, cameraIndex = this.$store.getters.currentCameraIndex) {
let msg = this.$msgPack.encode({
[key]: value,
["cameraIndex"]: cameraIndex,
});
this.$store.state.websocket.ws.send(msg);
},
handleData(val) {
this.handleInput(val, this[val]);
this.$emit('update')
},
handlePipelineData(val) {
let msg = this.$msgPack.encode({
["changePipelineSetting"]: {
[val]: this[val],
["cameraIndex"]: this.$store.getters.currentCameraIndex
}
});
this.$store.state.websocket.ws.send(msg);
this.$emit('update')
},
handlePipelineUpdate(key, val) {
let msg = this.$msgPack.encode({
["changePipelineSetting"]: {
[key]: val,
["cameraIndex"]: this.$store.getters.currentCameraIndex
}
});
this.$store.state.websocket.ws.send(msg);
this.$emit('update')
},
handleTruthyPipelineData(val) {
let msg = this.$msgPack.encode({
["changePipelineSetting"]: {
[val]: !!(this[val]),
["cameraIndex"]: this.$store.getters.currentCameraIndex
}
});
this.$store.state.websocket.ws.send(msg);
this.$emit('update')
},
rollback(val, e) {
//TODO UPDATE VALUES INTO WEBSOCKET
this.$store.commit('updatePipeline', {[val]: e})
}
}
};

View File

@@ -1,10 +0,0 @@
export const stateMixin = {
methods: {
currentPipelineType() {
return this.$store.getters.pipelineType
},
currentPipelineSettings() {
return this.$store.getters.currentPipelineSettings
},
}
};

View File

@@ -1,127 +0,0 @@
let canvas = undefined;
let image = undefined;
function initColorPicker() {
if (!canvas)
canvas = document.createElement('canvas');
image = document.querySelector('#raw-stream');
if (image !== null) {
canvas.width = image.width;
canvas.height = image.height;
}
}
//Called on click of the image,
//Finds X,Y of the mouse on the image,
//Draws the image on the (invisible) canvas
//Reads the color values (pixelData) in X,Y of the canvas
//calls the function to handle the button (either eyedrop,expand or shrink)
function colorPickerClick(event, currentFunction, currentRange) {
let rect = image.getBoundingClientRect();
let x = Math.round((event.clientX - rect.left) / rect.width * image.width);
let y = Math.round((event.clientY - rect.top) / rect.height * image.height);
let context = canvas.getContext('2d');
context.drawImage(image, 0, 0, image.width, image.height);
let pixelData = context.getImageData(x, y, 1, 1).data;
if (currentFunction !== undefined) {
return currentFunction(pixelData, currentRange);
}
}
function eyeDrop(pixel) {
let hsv = RGBtoHSV(pixel);
//sends hsv and a copy of hsv
return widenRange([hsv, hsv.slice(0)])
}
function expand(pixel, currentRange) {
let hsv = RGBtoHSV(pixel);
let widenHSV = widenRange([[].concat(hsv), hsv]);
return createRange(currentRange.concat(widenHSV));
}
function shrink(pixel, currentRange) {
let hsv = RGBtoHSV(pixel);
let widenHSV = widenRange([[].concat(hsv), hsv]);
if (!shrinkRange(currentRange, widenHSV[0])) //Tries to shrink the lower part of to widened HSV
shrinkRange(currentRange, widenHSV[1]); //If the prev attempt failed, try to shrink the higher part of to widened HSV
return currentRange
}
//numbers is an array of 3 rgb values, returns array for 3 hsv values
function RGBtoHSV(numbers) {
let r = numbers[0],
g = numbers[1],
b = numbers[2];
r = r / 255;
g = g / 255;
b = b / 255;
let minRGB = Math.min(r, Math.min(g, b));
let maxRGB = Math.max(r, Math.max(g, b));
let d = (r === minRGB) ? g - b : ((b === minRGB) ? r - g : b - r);
let h = (r === minRGB) ? 3 : ((b === minRGB) ? 1 : 5);
let H = 30 * (h - d / (maxRGB - minRGB));
let S = 255 * (maxRGB - minRGB) / maxRGB;
let V = 255 * maxRGB;
if (isNaN(H))
H = 0;
if (isNaN(S))
S = 0;
if (isNaN(V))
V = 0;
return [Math.round(H), Math.round(S), Math.round(V)];
}
//Loops though the colors array, finds the smallest and biggest value for H,S and V. Returns the range containing every color
function createRange(HSVColors) {
let range = [[], []];
for (let i = 0; i < 3; i++) {
range[0][i] = HSVColors[0][i];
range[1][i] = HSVColors[0][i];
for (let j = HSVColors.length - 1; j >= 0; j--) {
range[0][i] = Math.min(HSVColors[j][i], range[0][i]);
range[1][i] = Math.max(HSVColors[j][i], range[1][i]);
}
}
return range;//[[Hmin,Smin,Vmin],[Hmax,Smax,Vmax]]
}
//This function adds 10 extra units to each side of the sliders, not to be confused with the expand selection button
function widenRange(range) {
let expanded = [[], []];
for (let i = 0; i < 3; i++) {
//Expanding the range by 10
expanded[0][i] = Math.max(0, range[0][i] - 10);
expanded[1][i] = Math.min(255, range[1][i] + 10);
}
expanded[1][0] = Math.min(180, expanded[1][0]);//h is up to 180
return expanded;
}
//If color in range then take the closer range value to color and set it to color plus or minus 10
//For example if hmax is 200 hmin is 100 and color's h is 120 range will become [130,200]
function shrinkRange(range, color) {
let inside = true;
for (let i = 0; i < color.length && inside; i++) {//Check if color is in range
if (!(range[0][i] <= color[i] <= range[1][i]))
inside = false;
}
if (inside) {
for (let j = 0; j < color.length; j++) {
if (color[j] - range[0][j] < range[1][j] - color[j])
range[0][j] = Math.min(range[0][j] + 10, range[1][j]);//shrink from min side
else
range[1][j] = Math.max(range[1][j] - 10, range[0][j]);//shrink from max side
}
}
return inside;//returns if color is inside or not
}
export default {initColorPicker, colorPickerClick, eyeDrop, expand, shrink}

View File

@@ -1,74 +0,0 @@
/**
* Auto-reconnecting Websocket, a stripped down version of the NT4 client from
* https://raw.githubusercontent.com/wpilibsuite/NetworkTablesClients/2f8d378ac08d5ca703d590cfb019fc4af062db89/nt4/js/src/nt4.js
*/
export class ReconnectingWebsocket {
constructor(serverAddr,
onDataIn_in,
onConnect_in,
onDisconnect_in) {
this.onDataIn = onDataIn_in;
this.onConnect = onConnect_in;
this.onDisconnect = onDisconnect_in;
// WS Connection State (with defaults)
this.serverAddr = serverAddr;
this.serverConnectionActive = false;
//Trigger the websocket to connect automatically
this.ws_connect();
}
//////////////////////////////////////////////////////////////
// Websocket connection Maintenance
ws_onOpen() {
// Set the flag allowing general server communication
this.serverConnectionActive = true;
console.log("[WebSocket] Connected!");
// User connection-opened hook
this.onConnect();
}
ws_onClose(e) {
//Clear flags to stop server communication
this.ws = null;
this.serverConnectionActive = false;
// User connection-closed hook
this.onDisconnect();
console.log('[WebSocket] Socket is closed. Reconnect will be attempted in 0.5 second.', e.reason);
setTimeout(this.ws_connect.bind(this), 500);
if (!e.wasClean) {
console.error('Socket encountered error!');
}
}
ws_onError(e) {
console.log("[WebSocket] Websocket error - " + e.toString());
this.ws.close();
}
ws_onMessage(e) {
this.onDataIn(e);
}
ws_connect() {
this.ws = new WebSocket(this.serverAddr);
this.ws.binaryType = "arraybuffer";
this.ws.onopen = this.ws_onOpen.bind(this);
this.ws.onmessage = this.ws_onMessage.bind(this);
this.ws.onclose = this.ws_onClose.bind(this);
this.ws.onerror = this.ws_onError.bind(this);
console.log("[WebSocket] Starting...");
}
}
export default { ReconnectingWebsocket }

View File

@@ -1,359 +0,0 @@
// Circular buffer storage. Externally-apparent 'length' increases indefinitely
// while any items with indexes below length-n will be forgotten (undefined
// will be returned if you try to get them, trying to set is an exception).
// n represents the initial length of the array, not a maximum
class StatsHistoryBuffer{
constructor (){
this.windowLen = 10;
this._array= new Array(this.windowLen);
this.headPtr = 0;
this.frameCount = 0;
this.bitAvgAccum = 0;
//calculated values
this.bitRate_Mbps = 0;
this.framerate_fps = 0;
}
putAndPop(v){
this.headPtr++;
const idx = (this.headPtr) % this._array.length;
const poppedVal = this._array[idx];
this._array[idx] = v;
return poppedVal;
}
addSample(time, frameSize_bits, dispFrame_count) {
const oldVal = this.putAndPop([time, frameSize_bits, dispFrame_count]);
this.bitAvgAccum += frameSize_bits;
if(oldVal !=null){
const oldTime = oldVal[0];
const oldFrameSize = oldVal[1];
const oldFrameCount = oldVal[2];
const deltaTime_s = (time - oldTime);
this.bitAvgAccum -= oldFrameSize;
//bitrate - total bits transferred over the time period, divided by the period length
// converted to mbps
this.bitRate_Mbps = ( this.bitAvgAccum / deltaTime_s ) * (1.0/1048576.0);
//framerate - total frames displayed over the time period, divided by the period length
this.framerate_fps = (dispFrame_count - oldFrameCount) / deltaTime_s;
}
}
getText(){
return "Streaming @ " + this.framerate_fps.toFixed(1) + "FPS " + this.bitRate_Mbps.toFixed(1) + "Mbps";
}
}
export class WebsocketVideoStream{
constructor(drawDiv, streamPort, host) {
console.log("host " + host + " port " + streamPort)
this.drawDiv = drawDiv;
this.image = document.getElementById(this.drawDiv);
this.streamPort = streamPort;
this.newStreamPortReq = null;
this.serverAddr = "ws://" + host + "/websocket_cameras";
this.imgData = null;
this.imgDataTime = -1;
this.prevImgDataTime = -1;
this.imgObjURL = null;
this.frameRxCount = 0;
this.dispFrameCount = 0;
this.stats = null;
//Set up div for stream stats info provided for users
this.statsTextDiv = this.image.parentNode.appendChild(document.createElement("div"));
//Centered over the image
this.statsTextDiv.style.position = "absolute";
this.statsTextDiv.style.left = "50%";
this.statsTextDiv.style.top = "50%";
this.statsTextDiv.style.transform = "translate(-50%, -50%)";
// Big enough for a line or two of text, with centered text
this.statsTextDiv.style.padding = "0.5em"
this.statsTextDiv.style.overflow = "hidden";
this.statsTextDiv.style.textAlign = "center";
this.statsTextDiv.style.verticalAlign = "middle";
// Styled to be black with grey text
this.statsTextDiv.style.backgroundColor = "black";
this.statsTextDiv.style.color = "#9E9E9E";
this.statsTextDiv.style.borderRadius = "3px";
//Default no text
this.statsTextDiv.innerHTML = "";
// Only show on mouseover, with opacity fade-in/fade-out
this.statsTextDiv.style.opacity = "0.0";
this.statsTextDiv.style.transition = "opacity 0.25s ease 0.25s";
this.statsTextDiv.style.transitionDelay = "opacity 0.5s";
this.image.addEventListener('mouseover', () => {this.statsTextDiv.style.opacity = "0.6";});
this.statsTextDiv.addEventListener('mouseover', () => {this.statsTextDiv.style.opacity = "0.6";});
this.image.addEventListener('mouseout', () => {this.statsTextDiv.style.opacity = "0.0";});
//Display state machine descriptions
this.DSM_DISCONNECTED = "Disconnected";
this.DSM_WAIT_FOR_VALID_PORT = "Waiting for valid port ID";
this.DSM_SUBSCRIBE = "Subscribing";
this.DSM_WAIT_FOR_FIRST_FRAME = "Waiting for frame data";
this.DSM_SHOWING = "Showing Frames";
this.DSM_RESTART_UNSUBSCRIBE = "Unsubscribing";
this.DSM_RESTART_WAIT = "Waiting before resubscribe";
this.dsm_cur_state = this.DSM_DISCONNECTED;
this.dsm_prev_state = this.DSM_DISCONNECTED;
this.dsm_restart_start_time = window.performance.now();
this.dispNoStream();
this.ws_connect();
requestAnimationFrame(()=>this.animationLoop());
}
dispImageData(){
if(this.prevImgDataTime !== this.imgDataTime){
//From https://stackoverflow.com/questions/67507616/set-image-src-from-image-blob/67507685#67507685
//Ensure uniqueness by making the new one before revoking the old one.
const oldURL = this.imgObjURL;
this.imgObjURL = URL.createObjectURL(this.imgData);
if(oldURL != null){
URL.revokeObjectURL(oldURL)
}
//Update the image with the new mimetype and image
this.image.src = this.imgObjURL;
this.dispFrameCount++;
this.prevImgDataTime = this.imgDataTime;
} // else no new image, don't update anything
}
dispNoStream() {
this.image.src = require("../../public/loading.svg");
}
animationLoop(){
// Update time metrics
const curTime_s = window.performance.now() / 1000.0;
const timeInState = curTime_s - this.dsm_restart_start_time;
// Save previous state
this.dsm_prev_state = this.dsm_cur_state;
// Evaluate state transitions
if(!this.serverConnectionActive){
//Any state - if the server connection goes false, always transition to disconnected
this.dsm_cur_state = this.DSM_DISCONNECTED;
} else {
//Conditional transitions
switch(this.dsm_cur_state) {
case this.DSM_DISCONNECTED:
//Immediately transition to waiting for the first frame
this.dsm_cur_state = this.DSM_WAIT_FOR_VALID_PORT;
break;
case this.DSM_WAIT_FOR_VALID_PORT:
// Wait until the user has configured a valid port
if(this.streamPort > 0){
this.dsm_cur_state = this.DSM_SUBSCRIBE;
} else {
this.dsm_cur_state = this.DSM_WAIT_FOR_VALID_PORT;
}
break;
case this.DSM_SUBSCRIBE:
// Immediately transition after subscriptions is sent
this.dsm_cur_state = this.DSM_WAIT_FOR_FIRST_FRAME;
break;
case this.DSM_WAIT_FOR_FIRST_FRAME:
if(this.imgData != null){
//we got some image data, start showing it
this.dsm_cur_state = this.DSM_SHOWING;
} else if (this.newStreamPortReq != null){
//Stream port requested changed, unsubscribe and restart
this.dsm_cur_state = this.DSM_RESTART_UNSUBSCRIBE;
} else {
this.dsm_cur_state = this.DSM_WAIT_FOR_FIRST_FRAME;
}
break;
case this.DSM_SHOWING:
if((curTime_s - this.imgDataTime) > 2.5){
//timeout, begin the restart sequence
this.dsm_cur_state = this.DSM_RESTART_UNSUBSCRIBE;
} else if (this.newStreamPortReq != null){
//Stream port requested changed, unsubscribe and restart
this.dsm_cur_state = this.DSM_RESTART_UNSUBSCRIBE;
} else {
//stay in this state.
this.dsm_cur_state = this.DSM_SHOWING;
}
break;
case this.DSM_RESTART_UNSUBSCRIBE:
//Only should spend one loop in Unsubscribe, immediately transition
this.dsm_cur_state = this.DSM_RESTART_WAIT;
break;
case this.DSM_RESTART_WAIT:
if (timeInState > 0.25) {
//we've waited long enough, go to try to re-subscribe
this.dsm_cur_state = this.DSM_WAIT_FOR_VALID_PORT;
} else {
//stay in this state.
this.dsm_cur_state = this.DSM_RESTART_WAIT;
}
break;
default:
// Shouldn't get here, default back to init
this.dsm_cur_state = this.DSM_DISCONNECTED;
}
}
//take current-state or state-transition actions
if(this.dsm_cur_state !== this.dsm_prev_state){
//Any state transition
console.log("State Change: " + this.dsm_prev_state + " -> " + this.dsm_cur_state);
}
if(this.dsm_cur_state === this.DSM_SHOWING){
// Currently in SHOWING
// Show image and update status text
this.dispImageData();
this.statsTextDiv.innerHTML = this.stats.getText();
} else {
//Just show the state for debug
this.statsTextDiv.innerHTML = this.dsm_cur_state;
}
if(this.dsm_cur_state !== this.DSM_SHOWING && this.dsm_prev_state === this.DSM_SHOWING ){
//Any transition out of showing - no stream
this.dispNoStream();
}
if(this.dsm_cur_state === this.DSM_RESTART_UNSUBSCRIBE){
// Currently in UNSUBSCRIBE, do the unsubscribe actions
this.stopStream();
this.dsm_restart_start_time = curTime_s;
}
if(this.dsm_cur_state === this.DSM_SUBSCRIBE){
// Currently in SUBSCRIBE, do the subscribe actions
this.startStream();
this.dsm_restart_start_time = curTime_s;
}
if(this.dsm_cur_state === this.DSM_WAIT_FOR_VALID_PORT){
// Currently waiting for a vaild port to be requested
if(this.newStreamPortReq != null){
this.streamPort = this.newStreamPortReq;
this.newStreamPortReq = null;
}
}
//Update status text
requestAnimationFrame(()=>this.animationLoop());
}
startStream() {
console.log("Subscribing to port " + this.streamPort);
this.imgData = null;
this.ws.send(JSON.stringify({"cmd": "subscribe", "port":this.streamPort}));
}
stopStream() {
console.log("Unsubscribing");
this.ws.send(JSON.stringify({"cmd": "unsubscribe"}));
this.imgData = null;
}
setPort(streamPort){
console.log("Port set to " + streamPort);
this.newStreamPortReq = streamPort;
}
ws_onOpen() {
// Set the flag allowing general server communication
this.serverConnectionActive = true;
console.log("Camera Websockets Connected!");
// New websocket connection, reset stats
this.frameRxCount = 0;
this.dispFrameCount = 0;
this.stats = new StatsHistoryBuffer();
}
ws_onClose(e) {
//Clear flags to stop server communication
this.ws = null;
this.serverConnectionActive = false;
console.log('Camera Socket is closed. Reconnect will be attempted in 0.5 second.', e.reason);
setTimeout(this.ws_connect.bind(this), 500);
if(!e.wasClean){
console.error('Socket encountered error!');
}
}
ws_onError(e){
e; //prevent unused failure
this.ws.close();
}
ws_onMessage(e){
//console.log("Got message from " + this.serverAddr)
const msgTime_s = window.performance.now() / 1000.0;
if(typeof e.data === 'string'){
//string data from host
//TODO - anything to receive info here? Maybe "available streams?"
} else {
if(e.data.size > 0){
//binary data - a frame!
//Save frame data for display in the next animation thread
this.imgData = e.data;
this.imgDataTime = msgTime_s;
//Count the incoming frame
this.frameRxCount++;
//keep the stats up to date
this.stats.addSample(msgTime_s,this.imgData.size * 8,this.dispFrameCount);
} else {
console.log("WS Stream Error: Server sent empty frame!");
}
}
}
ws_connect() {
this.serverConnectionActive = false;
this.ws = new WebSocket(this.serverAddr);
this.ws.binaryType = "blob";
this.ws.onopen = this.ws_onOpen.bind(this);
this.ws.onmessage = this.ws_onMessage.bind(this);
this.ws.onclose = this.ws_onClose.bind(this);
this.ws.onerror = this.ws_onError.bind(this);
console.log("Connecting to server " + this.serverAddr);
}
ws_close(){
this.ws.close();
}
}
export default {WebsocketVideoStream}

View File

@@ -1,22 +0,0 @@
import '@mdi/font/css/materialdesignicons.css';
import Vue from 'vue';
import Vuetify from 'vuetify/lib';
import theme from "../theme";
Vue.use(Vuetify, {});
// Although you *can* set up theming here, it's so frequently inappropriate that we do it in the markup
export default new Vuetify({
theme: {
themes: {
light: theme,
dark: theme,
}
},
breakpoint: {
thresholds: {
md: 1460,
lg: 2000,
},
}
});

View File

@@ -0,0 +1,45 @@
import Vue from "vue";
import Vuetify from "vuetify";
import "vuetify/dist/vuetify.min.css";
import "@mdi/font/css/materialdesignicons.css";
import type { VuetifyThemeVariant } from "vuetify/types/services/theme";
Vue.use(Vuetify);
const darkTheme: VuetifyThemeVariant = Object.freeze({
primary: "#006492",
secondary: "#39A4D5",
accent: "#FFD843",
background: "#232C37",
error: "#FF5252",
info: "#2196F3",
success: "#4CAF50",
warning: "#FFC107"
});
const lightTheme: VuetifyThemeVariant = Object.freeze({
primary: "#006492",
secondary: "#39A4D5",
accent: "#FFD843",
background: "#232C37",
error: "#FF5252",
info: "#2196F3",
success: "#4CAF50",
warning: "#FFC107"
});
export default new Vuetify({
theme: {
themes: {
light: lightTheme,
dark: darkTheme
}
},
breakpoint: {
thresholds: {
md: 1460,
lg: 2000
}
}
});

View File

@@ -1,47 +0,0 @@
import Vue from 'vue'
import VueRouter from "vue-router";
import Dashboard from "./views/PipelineView";
import Cameras from "./views/CamerasView";
import Settings from "./views/SettingsView";
import Docs from "./views/DocsView";
import NotFoundView from "./views/NotFoundView";
Vue.use(VueRouter);
export default new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes: [
{
path: '/',
redirect: '/dashboard'
},
{
path: '/dashboard',
name: 'Dashboard',
component: Dashboard
},
{
path: '/cameras',
name: 'Cameras',
component: Cameras
},
{
path: '/settings',
name: 'Settings',
component: Settings
},
{
path: '/docs',
name: 'Docs',
component: Docs
},
{
path: "*",
name: "NotFound",
component: NotFoundView
}
]
})

View File

@@ -0,0 +1,48 @@
import Vue from "vue";
import VueRouter from "vue-router";
import DashboardView from "@/views/DashboardView.vue";
import CameraSettingsView from "@/views/CameraSettingsView.vue";
import GeneralSettingsView from "@/views/GeneralSettingsView.vue";
import DocsView from "@/views/DocsView.vue";
import NotFoundView from "@/views/NotFoundView.vue";
Vue.use(VueRouter);
const router = new VueRouter({
mode: "history",
base: import.meta.env.BASE_URL,
routes: [
{
path: "/",
redirect: "/dashboard"
},
{
path: "/dashboard",
name: "Dashboard",
component: DashboardView
},
{
path: "/cameras",
name: "Cameras",
component: CameraSettingsView
},
{
path: "/settings",
name: "Settings",
component: GeneralSettingsView
},
{
path: "/docs",
name: "Docs",
component: DocsView
},
{
path: "*",
name: "NotFound",
component: NotFoundView
}
]
});
export default router;

View File

@@ -1,8 +0,0 @@
@import "~@fontsource/prompt/index.css";
$default-font: "Prompt",
sans-serif !default;
$body-font-family: $default-font;
$heading-font-family: $default-font;
.v-application {
font-family: $default-font !important;
}

View File

@@ -1,289 +0,0 @@
import Vue from 'vue'
import Vuex from 'vuex'
import undoRedo from "./modules/undoRedo";
Vue.use(Vuex);
const set = key => (state, val) => {
Vue.set(state, key, val);
};
export default new Vuex.Store({
modules: {
undoRedo: undoRedo
},
state: {
backendConnected: false,
websocket: null,
ntConnectionInfo: {
connected: false,
address: "",
clients: 0,
},
networkInfo: {
possibleRios: ["Loading..."],
deviceips: ["Loading..."],
},
connectedCallbacks: [],
colorPicking: false,
logsOverlay: false,
compactMode: localStorage.getItem("compactMode") === undefined ? undefined : localStorage.getItem("compactMode") === "true", // Compact mode is initially unset on purpose
logMessages: [],
currentCameraIndex: 0,
cameraSettings: [ // This is a list of objects representing the settings of all cameras
{
tiltDegrees: 0.0,
currentPipelineIndex: 0,
pipelineNicknames: ["Unknown"],
outputStreamPort: 0,
inputStreamPort: 0,
nickname: "Unknown",
videoFormatList: [
{
"width": 1920,
"height": 1080,
"fps": 30,
"pixelFormat": "BGR"
}
],
calibrations: [ ],
fov: 70.0,
isFovConfigurable: true,
calibrated: false,
currentPipelineSettings: {
pipelineType: 5, // One of "calib", "driver", "reflective", "shape", "AprilTag"
// 2 is reflective
// Settings that apply to all pipeline types
cameraExposure: 1,
cameraBrightness: 2,
cameraAutoExposure: false,
cameraRedGain: 3,
cameraBlueGain: 4,
inputImageRotationMode: 0,
cameraVideoModeIndex: 0,
streamingFrameDivisor: 0,
// Settings that apply to reflective
hsvHue: [0, 15],
hsvSaturation: [0, 15],
hsvValue: [0, 25],
hueInverted: false,
contourArea: [0, 12],
contourRatio: [0, 12],
contourFullness: [0, 12],
contourSpecklePercentage: 5,
contourFilterRangeX: 5,
contourFilterRangeY: 5,
contourGroupingMode: 0,
contourIntersection: 0,
contourSortMode: 0,
inputShouldShow: true,
outputShouldShow: true,
outputShouldDraw: true,
outputShowMultipleTargets: false,
offsetRobotOffsetMode: 0,
solvePNPEnabled: false,
targetRegion: 0,
contourTargetOrientation: 1,
cornerDetectionAccuracyPercentage: 10,
// Settings that apply to AprilTag
tagFamily: 1,
decimate: 1.0,
blur: 0.0,
threads: 1,
debug: false,
refineEdges: true,
numIterations: 30,
decisionMargin: 0,
hammingDist: 0,
}
}
],
pipelineResults: {
fps: 0,
latency: 0,
targets: [
{
// Available in both 2D and 3D
pitch: 0,
yaw: 0,
skew: 0,
area: 0,
// 3D only
pose: {x: 1, y: 1, z: 0, qw: 1, qx: 0, qy: 0, qz: 0},
},
{
// Available in both 2D and 3D
pitch: 0,
yaw: 0,
skew: 0,
area: 0,
// 3D only
pose: {x: 2, y: 3, z: 0, qw: 1, qx: 0, qy: 0, qz: 0},
}
]
},
settings: {
general: {
version: "Unknown",
// Empty string means unsupported, otherwise the value in the string is the transfer mode
gpuAcceleration: "",
hardwareModel: "Unknown",
hardwarePlatform: "Unknown",
},
networkSettings: {
ntServerAddress: "",
supported: true,
// Below options are only configurable if supported is true
connectionType: 0, // 0 = DHCP, 1 = Static
staticIp: "",
hostname: "photonvision",
runNTServer: false,
},
lighting: {
supported: true,
brightness: 0.0,
},
},
calibrationData: {
count: 0,
videoModeIndex: 0,
minCount: 12, // Gets set by backend anyway, but we need a sane default
hasEnough: false,
squareSizeIn: 1.0,
patternWidth: 8,
patternHeight: 8,
boardType: 0, // Chessboard, dotboard
},
metrics: {
cpuTemp: "N/A",
cpuUtil: "N/A",
cpuMem: "N/A",
gpuMem: "N/A",
ramUtil: "N/A",
gpuMemUtil: "N/A",
}
},
mutations: {
compactMode: set('compactMode'),
websocket: set('websocket'),
cameraSettings: set('cameraSettings'),
currentCameraIndex: set('currentCameraIndex'),
selectedOutputs: set('selectedOutputs'),
settings: set('settings'),
calibrationData: set('calibrationData'),
metrics: set('metrics'),
ntConnectionInfo: set('ntConnectionInfo'),
networkInfo: set('networkInfo'),
backendConnected: set('backendConnected'),
logString: (state, newStr) => {
const str = state.logMessages;
str.push(newStr);
Vue.set(state, 'logString', str)
},
solvePNPEnabled: (state, val) => {
state.cameraSettings[state.currentCameraIndex].currentPipelineSettings.solvePNPEnabled = val;
},
currentPipelineIndex: (state, val) => {
const settings = state.cameraSettings[state.currentCameraIndex];
Vue.set(settings, 'currentPipelineIndex', val);
},
// TODO change everything to use this
mutatePipeline: (state, payload) => {
for (let key in payload) {
if (!payload.hasOwnProperty(key)) continue;
const value = payload[key];
const settings = state.cameraSettings[state.currentCameraIndex].currentPipelineSettings;
if (settings.hasOwnProperty(key)) {
Vue.set(settings, key, value);
}
}
},
mutateSettings: (state, payload) => {
for (let key in payload) {
if (!payload.hasOwnProperty(key)) continue;
const value = payload[key];
const settings = state.settings;
if (settings.hasOwnProperty(key)) {
Vue.set(settings, key, value);
}
}
},
mutateNetworkSettings: (state, payload) => {
for (let key in payload) {
if (!payload.hasOwnProperty(key)) continue;
const value = payload[key];
const settings = state.settings.networkSettings;
if (settings.hasOwnProperty(key)) {
Vue.set(settings, key, value);
}
}
},
mutatePipelineResults(state, payload) {
// Key: index, value: result
for (let key in payload) {
if (!payload.hasOwnProperty(key)) continue;
const index = parseInt(key);
if(index === state.currentCameraIndex) {
Vue.set(state, 'pipelineResults', payload[key])
}
}
},
mutateEnabledLEDPercentage(state, payload) {
const settings = state.settings;
settings.lighting.brightness = payload;
Vue.set(state, "settings", settings);
},
mutateCalibrationState: (state, payload) => {
for (let key in payload) {
if (!payload.hasOwnProperty(key)) continue;
const value = payload[key];
const calibration = state.calibrationData;
if (calibration.hasOwnProperty(key)) {
calibration[key] = value
}
Vue.set(state, 'calibrationData', calibration)
}
},
},
getters: {
isDriverMode: state => state.cameraSettings[state.currentCameraIndex].currentPipelineIndex === -1,
streamAddress: state =>
["http://" + location.hostname + ":" + state.cameraSettings[state.currentCameraIndex].inputStreamPort + "/stream.mjpg",
"http://" + location.hostname + ":" + state.cameraSettings[state.currentCameraIndex].outputStreamPort + "/stream.mjpg"],
currentPipelineResults: state => {
return state.pipelineResults;
},
isCalibrated: state => {
let resolution = state.cameraSettings[state.currentCameraIndex].videoFormatList[state.cameraSettings[state.currentCameraIndex].currentPipelineSettings.cameraVideoModeIndex];
return state.cameraSettings[state.currentCameraIndex].calibrations
.some(e => e.width === resolution.width && e.height === resolution.height);
},
cameraList: state => state.cameraSettings.map(it => it.nickname),
currentCameraSettings: state => state.cameraSettings[state.currentCameraIndex],
currentCameraIndex: state => state.currentCameraIndex,
currentPipelineIndex: state => state.cameraSettings[state.currentCameraIndex].currentPipelineIndex,
currentPipelineSettings: state => state.cameraSettings[state.currentCameraIndex].currentPipelineSettings,
currentVideoFormat: state => state.cameraSettings[state.currentCameraIndex].videoFormatList[state.cameraSettings[state.currentCameraIndex].currentPipelineSettings.cameraVideoModeIndex],
videoFormatList: state => {
return Object.values(state.cameraSettings[state.currentCameraIndex].videoFormatList); // convert to a list
},
pipelineList: state => state.cameraSettings[state.currentCameraIndex].pipelineNicknames,
calibrationList: state => state.cameraSettings[state.currentCameraIndex].calibrations,
pipelineType: state => state.cameraSettings[state.currentCameraIndex].currentPipelineSettings.pipelineType
}
})

View File

@@ -1,72 +0,0 @@
// eslint-disable-next-line no-unused-vars
import Vue from 'vue'
export default {
state: {
done: [],
undone: [],
newMutation: true
},
mutations: {
updatePipeline: (state, val) => {
state.done.push(val)
if (state.newMutation) {
state.undone = []
}
},
addUndone: (state, val) => {
state.undone.push(val);
},
removeLastDone: state => {
state.done.pop()
},
removeLastUnDone: state => {
state.undone.pop()
},
updateStatus: (state, bool) => {
state.newMutation = bool;
},
},
actions: {
undo: (context, {vm}) => {
let commit = context.getters.lastDone;
context.commit('removeLastDone')
context.commit('updateStatus', false)
for (let key in commit) {
if (commit.hasOwnProperty(key)) {
context.commit('addUndone', {[key]: context.getters["pipeline"][key]});
context.commit('mutatePipeline', {'key': key, 'value': commit[key]});
vm.handleInput(key, commit[key]);
}
}
context.commit('updateStatus', true)
},
redo: (context, {vm}) => {
let commit = context.getters.lastUnDone;
context.commit('removeLastUnDone');
context.commit('updateStatus', false)
for (let key in commit) {
if (commit.hasOwnProperty(key)) {
context.commit('mutatePipeline', {'key': key, 'value': commit[key]});
vm.handleInput(key, commit[key]);
}
}
context.commit('updateStatus', true)
}
},
getters: {
lastDone: state => {
return state.done[state.done.length - 1]
},
lastUnDone: state => {
return state.undone[state.undone.length - 1]
},
canUndo: state => {
return state.done.length
},
canRedo: state => {
return state.undone.length
}
}
};

View File

@@ -0,0 +1,125 @@
import { defineStore } from "pinia";
import type { LogMessage } from "@/types/SettingTypes";
import type { AutoReconnectingWebsocket } from "@/lib/AutoReconnectingWebsocket";
import type { PipelineResult } from "@/types/PhotonTrackingTypes";
import type {
WebsocketCalibrationData,
WebsocketLogMessage,
WebsocketNTUpdate,
WebsocketPipelineResultUpdate
} from "@/types/WebsocketDataTypes";
export interface NTConnectionStatus {
connected: boolean,
address?: string,
clients?: number
}
interface StateStore {
backendConnected: boolean,
websocket?: AutoReconnectingWebsocket,
ntConnectionStatus: NTConnectionStatus,
showLogModal: boolean,
sidebarFolded: boolean,
logMessages: LogMessage[]
currentCameraIndex: number,
pipelineResults?: PipelineResult,
colorPickingMode: boolean,
calibrationData: {
imageCount: number,
videoFormatIndex: number,
minimumImageCount: number,
hasEnoughImages: boolean
},
snackbarData: {
show: boolean,
message: string,
color: string,
timeout: number
}
}
export const useStateStore = defineStore("state", {
state: (): StateStore => {
return {
backendConnected: false,
websocket: undefined,
ntConnectionStatus: {
connected: false
},
showLogModal: false,
// Ignored if the display is too small
sidebarFolded: localStorage.getItem("sidebarFolded") === null ? false : localStorage.getItem("sidebarFolded") === "true",
logMessages: [],
currentCameraIndex: 0,
pipelineResults: undefined,
colorPickingMode: false,
calibrationData: {
imageCount: 0,
videoFormatIndex: 0,
minimumImageCount: 12,
hasEnoughImages: false
},
snackbarData: {
show: false,
message: "No Message",
color: "info",
timeout: 2000
}
};
},
actions: {
setSidebarFolded(value: boolean) {
this.sidebarFolded = value;
localStorage.setItem("sidebarFolded", Boolean(value).toString());
},
addLogFromWebsocket(data: WebsocketLogMessage) {
this.logMessages.push({
level: data.logMessage.logLevel,
message: data.logMessage.logMessage
});
},
updateNTConnectionStatusFromWebsocket(data: WebsocketNTUpdate) {
this.ntConnectionStatus = {
connected: data.connected,
address: data.address,
clients: data.clients
};
},
updatePipelineResultsFromWebsocket(data: WebsocketPipelineResultUpdate) {
for(const cameraIndex in data) {
if(parseInt(cameraIndex) === this.currentCameraIndex) {
this.pipelineResults = data[cameraIndex];
}
}
},
updateCalibrationStateValuesFromWebsocket(data: WebsocketCalibrationData) {
this.calibrationData = {
imageCount: data.count,
videoFormatIndex: data.videoModeIndex,
minimumImageCount: data.minCount,
hasEnoughImages: data.hasEnough
};
},
showSnackbarMessage(data: {
message: string,
color: string,
timeout?: number
}) {
this.snackbarData = {
show: true,
message: data.message,
color: data.color,
timeout: data.timeout || 2000
};
}
}
});

View File

@@ -0,0 +1,349 @@
import { defineStore } from "pinia";
import type {
CalibrationBoardTypes,
CameraCalibrationResult,
CameraSettings,
ConfigurableCameraSettings,
RobotOffsetType,
VideoFormat
} from "@/types/SettingTypes";
import { PlaceholderCameraSettings } from "@/types/SettingTypes";
import { useStateStore } from "@/stores/StateStore";
import type { WebsocketCameraSettingsUpdate } from "@/types/WebsocketDataTypes";
import { WebsocketPipelineType } from "@/types/WebsocketDataTypes";
import type { ActiveConfigurablePipelineSettings, ActivePipelineSettings } from "@/types/PipelineTypes";
import type { PipelineType } from "@/types/PipelineTypes";
import axios from "axios";
interface CameraSettingsStore {
cameras: CameraSettings[]
}
export const useCameraSettingsStore = defineStore("cameraSettings", {
state: (): CameraSettingsStore => ({
cameras: [
PlaceholderCameraSettings
]
}),
getters: {
// TODO update types to update this value being undefined. This would be a decently large change.
currentCameraSettings(): CameraSettings {
return this.cameras[useStateStore().currentCameraIndex];
},
currentPipelineSettings(): ActivePipelineSettings {
return this.currentCameraSettings.pipelineSettings;
},
currentPipelineType(): PipelineType {
return this.currentPipelineSettings.pipelineType;
},
// This method only exists due to just how lazy I am and my dislike of consolidating the pipeline type enums (which mind you, suck as is)
currentWebsocketPipelineType(): WebsocketPipelineType {
return this.currentPipelineType - 2;
},
currentVideoFormat(): VideoFormat {
return this.currentCameraSettings.validVideoFormats[this.currentPipelineSettings.cameraVideoModeIndex];
},
isCurrentVideoFormatCalibrated(): boolean {
return this.currentCameraSettings.completeCalibrations.some(v =>
v.resolution.width === this.currentVideoFormat.resolution.width
&& v.resolution.height === this.currentVideoFormat.resolution.height);
},
cameraNames(): string[] {
return this.cameras.map(c => c.nickname);
},
pipelineNames(): string[] {
return this.currentCameraSettings.pipelineNicknames;
},
isDriverMode(): boolean {
return this.currentCameraSettings.currentPipelineIndex === WebsocketPipelineType.DriverMode;
},
isCalibrationMode(): boolean {
return this.currentCameraSettings.currentPipelineIndex == WebsocketPipelineType.Calib3d;
}
},
actions: {
updateCameraSettingsFromWebsocket(data: WebsocketCameraSettingsUpdate[]) {
this.cameras = data.map<CameraSettings>((d) => ({
nickname: d.nickname,
fov: {
value: d.fov,
managedByVendor: !d.isFovConfigurable
},
stream: {
inputPort: d.inputStreamPort,
outputPort: d.outputStreamPort
},
validVideoFormats: Object.entries(d.videoFormatList)
.sort(([firstKey], [secondKey]) => parseInt(firstKey) - parseInt(secondKey))
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.map<VideoFormat>(([k, v], i) => ({
resolution: {
width: v.width,
height: v.height
},
fps: v.fps,
pixelFormat: v.pixelFormat,
index: v.index || i,
diagonalFOV: v.diagonalFOV,
horizontalFOV: v.horizontalFOV,
verticalFOV: v.verticalFOV,
standardDeviation: v.standardDeviation,
mean: v.mean
})),
completeCalibrations: d.calibrations.map<CameraCalibrationResult>(calib => ({
resolution: {
height: calib.height,
width: calib.width
},
distCoeffs: calib.distCoeffs,
standardDeviation: calib.standardDeviation,
perViewErrors: calib.perViewErrors,
intrinsics: calib.intrinsics
})),
pipelineNicknames: d.pipelineNicknames,
currentPipelineIndex: d.currentPipelineIndex,
pipelineSettings: d.currentPipelineSettings
}));
},
/**
* Update the configurable camera settings.
*
* @param data camera settings to save.
* @param updateStore whether or not to update the store. This is useful if the input field already models the store reference.
* @param cameraIndex the index of the camera.
*/
updateCameraSettings(data: ConfigurableCameraSettings, updateStore = true, cameraIndex: number = useStateStore().currentCameraIndex) {
// The camera settings endpoint doesn't actually require all data, instead, it needs key data such as the FOV
const payload = {
settings: {
...data
},
index: cameraIndex
};
if(updateStore) {
this.currentCameraSettings.fov.value = data.fov;
}
return axios.post("/settings/camera", payload);
},
/**
* Create a new Pipeline for the provided camera.
*
* @param newPipelineName the name of the new pipeline.
* @param pipelineType the type of the new pipeline. Cannot be {@link WebsocketPipelineType.Calib3d} or {@link WebsocketPipelineType.DriverMode}.
* @param cameraIndex the index of the camera
*/
createNewPipeline(newPipelineName: string, pipelineType: Exclude<WebsocketPipelineType, WebsocketPipelineType.Calib3d | WebsocketPipelineType.DriverMode>, cameraIndex: number = useStateStore().currentCameraIndex) {
const payload = {
addNewPipeline: [newPipelineName, pipelineType],
cameraIndex: cameraIndex
};
useStateStore().websocket?.send(payload, true);
},
/**
* Modify the settings of the currently selected pipeline of the provided camera.
*
* @param settings settings to modify. The type of the settings should match the currently selected pipeline type.
* @param updateStore whether or not to update the store. This is useful if the input field already models the store reference.
* @param cameraIndex the index of the camera
*/
changeCurrentPipelineSetting(settings: ActiveConfigurablePipelineSettings, updateStore = true, cameraIndex: number = useStateStore().currentCameraIndex) {
const payload = {
changePipelineSetting: {
...settings,
cameraIndex: cameraIndex
}
};
if(updateStore) {
this.changePipelineSettingsInStore(settings, cameraIndex);
}
useStateStore().websocket?.send(payload, true);
},
changePipelineSettingsInStore(settings: Partial<ActivePipelineSettings>, cameraIndex: number = useStateStore().currentCameraIndex) {
Object.entries(settings).forEach(([k, v]) => {
this.cameras[cameraIndex].pipelineSettings[k] = v;
});
},
/**
* Change the nickname of the currently selected pipeline of the provided camera.
*
* @param newName the new nickname for the camera.
* @param updateStore whether or not to update the store. This is useful if the input field already models the store reference.
* @param cameraIndex the index of the camera
*/
changeCurrentPipelineNickname(newName: string, updateStore = true, cameraIndex: number = useStateStore().currentCameraIndex) {
const payload = {
changePipelineName: newName,
cameraIndex: cameraIndex
};
if(updateStore) {
this.cameras[cameraIndex].pipelineSettings.pipelineNickname = newName;
}
useStateStore().websocket?.send(payload, true);
},
/**
* Modify the Pipeline type of the currently selected pipeline of the provided camera. This overwrites the current pipeline's settings when the backend resets the current pipeline settings.
*
* @param type the pipeline type to set. Cannot be {@link WebsocketPipelineType.Calib3d} or {@link WebsocketPipelineType.DriverMode}.
* @param cameraIndex the index of the camera.
*/
changeCurrentPipelineType(type: Exclude<WebsocketPipelineType, WebsocketPipelineType.Calib3d | WebsocketPipelineType.DriverMode>, cameraIndex: number = useStateStore().currentCameraIndex) {
const payload = {
pipelineType: type,
cameraIndex: cameraIndex
};
useStateStore().websocket?.send(payload, true);
},
/**
* Change the index of the pipeline of the currently selected camera.
*
* @param index pipeline index to set.
* @param updateStore whether or not to update the store. This is useful if the input field already models the store reference.
* @param cameraIndex the index of the camera.
*/
changeCurrentPipelineIndex(index: number, updateStore = true, cameraIndex: number = useStateStore().currentCameraIndex) {
const payload = {
currentPipeline: index,
cameraIndex: cameraIndex
};
if(updateStore) {
if(this.cameras[cameraIndex].currentPipelineIndex !== -1
&& this.cameras[cameraIndex].currentPipelineIndex !== -2) {
this.cameras[cameraIndex].lastPipelineIndex = this.cameras[cameraIndex].currentPipelineIndex;
}
this.cameras[cameraIndex].currentPipelineIndex = index;
}
useStateStore().websocket?.send(payload, true);
},
/**
* Change the currently selected pipeline of the provided camera.
*
* @param cameraIndex the index of the camera's pipeline to change.
*/
deleteCurrentPipeline(cameraIndex: number = useStateStore().currentCameraIndex) {
const payload = {
deleteCurrentPipeline: {},
cameraIndex: cameraIndex
};
useStateStore().websocket?.send(payload, true);
},
/**
* Duplicate the pipeline at the provided index.
*
* @param pipelineIndex index of the pipeline to duplicate.
* @param cameraIndex the index of the camera.
*/
duplicatePipeline(pipelineIndex: number, cameraIndex: number = useStateStore().currentCameraIndex) {
const payload = {
duplicatePipeline: pipelineIndex,
cameraIndex: cameraIndex
};
useStateStore().websocket?.send(payload, true);
},
/**
* Change the currently set camera
*
* @param cameraIndex the index of the camera to set as the current camera.
* @param updateStore whether or not to update the store. This is useful if the input field already models the store reference.
*/
setCurrentCameraIndex(cameraIndex: number, updateStore = true) {
const payload = {
currentCamera: cameraIndex
};
if(updateStore) {
useStateStore().currentCameraIndex = cameraIndex;
}
useStateStore().websocket?.send(payload, true);
},
/**
* Change the nickname of the provided camera.
*
* @param newName the new nickname of the camera.
* @param updateStore whether or not to update the store. This is useful if the input field already models the store reference.
* @param cameraIndex the index of the camera.
* @return HTTP request promise to the backend
*/
changeCameraNickname(newName: string, updateStore = true, cameraIndex: number = useStateStore().currentCameraIndex) {
const payload = {
name: newName,
cameraIndex: cameraIndex
};
if(updateStore) {
this.currentCameraSettings.nickname = newName;
}
return axios.post("/settings/camera/setNickname", payload);
},
/**
* Start the 3D calibration process for the provided camera.
*
* @param calibrationInitData initialization calibration data.
* @param cameraIndex the index of the camera.
*/
startPnPCalibration(calibrationInitData: {
squareSizeIn: number,
patternWidth: number,
patternHeight: number,
boardType: CalibrationBoardTypes
}, cameraIndex: number = useStateStore().currentCameraIndex) {
const stateCalibData = useStateStore().calibrationData;
const payload = {
startPnpCalibration: {
count: stateCalibData.imageCount,
minCount: stateCalibData.minimumImageCount,
hasEnough: stateCalibData.hasEnoughImages,
videoModeIndex: stateCalibData.videoFormatIndex,
...calibrationInitData
},
cameraIndex: cameraIndex
};
useStateStore().websocket?.send(payload, true);
},
/**
* End the 3D calibration process for the provided camera.
*
* @param cameraIndex the index of the camera
* @return HTTP request promise to the backend
*/
endPnPCalibration(cameraIndex: number = useStateStore().currentCameraIndex) {
return axios.post("/calibration/end", { index: cameraIndex });
},
/**
* Import calibration data that was computed using CalibDB.
*
* @param data Data from the uploaded CalibDB config
* @param cameraIndex the index of the camera
*/
importCalibDB(data: { payload: string, filename: string }, cameraIndex: number = useStateStore().currentCameraIndex) {
const payload = {
...data,
cameraIndex: cameraIndex
};
return axios.post("/calibration/importFromCalibDB", payload, { headers: { "Content-Type": "text/plain" } });
},
/**
* Take a snapshot for the calibration processes
*
* @param takeSnapshot whether or not to take a snapshot. Defaults to true
* @param cameraIndex the index of the camera that is currently in the calibration process
*/
takeCalibrationSnapshot(takeSnapshot = true, cameraIndex: number = useStateStore().currentCameraIndex) {
const payload = {
takeCalibrationSnapshot: takeSnapshot,
cameraIndex: cameraIndex
};
useStateStore().websocket?.send(payload, true);
},
/**
* Set the robot offset mode type.
*
* @param type Offset type to take.
* @param cameraIndex the index of the camera.
*/
takeRobotOffsetPoint(type: RobotOffsetType, cameraIndex: number = useStateStore().currentCameraIndex) {
const payload = {
robotOffsetPoint: type,
cameraIndex: cameraIndex
};
useStateStore().websocket?.send(payload, true);
}
}
});

View File

@@ -0,0 +1,111 @@
import { defineStore } from "pinia";
import type {
GeneralSettings,
LightingSettings,
MetricData,
NetworkSettings
} from "@/types/SettingTypes";
import { NetworkConnectionType } from "@/types/SettingTypes";
import { useStateStore } from "@/stores/StateStore";
import axios from "axios";
import type { WebsocketSettingsUpdate } from "@/types/WebsocketDataTypes";
interface GeneralSettingsStore {
general: GeneralSettings,
network: NetworkSettings,
lighting: LightingSettings,
metrics: MetricData
}
export const useSettingsStore = defineStore("settings", {
state: (): GeneralSettingsStore => ({
general: {
version: undefined,
gpuAcceleration: undefined,
hardwareModel: undefined,
hardwarePlatform: undefined
},
network: {
ntServerAddress: "",
shouldMange: true,
connectionType: NetworkConnectionType.DHCP,
staticIp: "",
hostname: "photonvision",
runNTServer: false
},
lighting: {
supported: true,
brightness: 0
},
metrics: {
cpuTemp: undefined,
cpuUtil: undefined,
cpuMem: undefined,
gpuMem: undefined,
ramUtil: undefined,
gpuMemUtil: undefined,
cpuThr: undefined,
cpuUptime: undefined,
diskUtilPct: undefined
}
}),
getters: {
gpuAccelerationEnabled(): boolean {
return this.general.gpuAcceleration !== undefined;
}
},
actions: {
requestMetricsUpdate() {
return axios.post("/utils/publishMetrics");
},
updateMetricsFromWebsocket(data: Required<MetricData>) {
this.metrics = {
cpuTemp: data.cpuTemp || undefined,
cpuUtil: data.cpuUtil || undefined,
cpuMem: data.cpuMem || undefined,
gpuMem: data.gpuMem || undefined,
ramUtil: data.ramUtil || undefined,
gpuMemUtil: data.gpuMemUtil || undefined,
cpuThr: data.cpuThr || undefined,
cpuUptime: data.cpuUptime || undefined,
diskUtilPct: data.diskUtilPct || undefined
};
},
updateGeneralSettingsFromWebsocket(data: WebsocketSettingsUpdate) {
this.general = {
version: data.general.version || undefined,
hardwareModel: data.general.hardwareModel || undefined,
hardwarePlatform: data.general.hardwarePlatform || undefined,
gpuAcceleration: data.general.gpuAcceleration || undefined
};
this.lighting = data.lighting;
this.network = data.networkSettings;
},
saveGeneralSettings() {
const payload: Required<NetworkSettings> = {
connectionType: this.network.connectionType,
hostname: this.network.hostname,
networkManagerIface: this.network.networkManagerIface || "",
ntServerAddress: this.network.ntServerAddress,
physicalInterface: this.network.physicalInterface || "",
runNTServer: this.network.runNTServer,
setDHCPcommand: this.network.setDHCPcommand || "",
setStaticCommand: this.network.setStaticCommand || "",
shouldMange: this.network.shouldMange,
staticIp: this.network.staticIp
};
return axios.post("/settings/general", payload);
},
/**
* Modify the brightness of the LEDs.
*
* @param brightness brightness to set [0, 100]
*/
changeLEDBrightness(brightness: number) {
const payload = {
enabledLEDPercentage: brightness
};
useStateStore().websocket?.send(payload, true);
}
}
});

View File

@@ -1,8 +0,0 @@
const theme = Object.freeze({
primary: "#006492",
secondary: "#39A4D5",
accent: "#FFD843",
background: "#232C37",
});
export default theme;

View File

@@ -0,0 +1,29 @@
export interface Pose {
x: number,
y: number,
z: number,
angle_z: number,
qw: number,
qx: number,
qy: number,
qz: number
}
export interface PhotonTarget {
yaw: number,
pitch: number,
skew: number,
area: number,
// -1 if not set
ambiguity: number,
// -1 if not set
fiducialId: number,
// undefined if 3d isn't enabled
pose?: Pose
}
export interface PipelineResult {
fps: number,
latency: number,
targets: PhotonTarget[]
}

View File

@@ -0,0 +1,244 @@
import type { WebsocketNumberPair } from "@/types/WebsocketDataTypes";
export enum PipelineType {
DriverMode=1,
Reflective=2,
ColoredShape=3,
AprilTag=4,
Aruco=5
}
export enum AprilTagFamily {
Family36h11=0,
Family25h9=1,
Family16h5=2
}
export enum RobotOffsetPointMode {
None=0,
Single=1,
Dual=2
}
export enum TargetModel {
InfiniteRechargeHighGoalOuter=0,
InfiniteRechargeHighGoalInner=1,
DeepSpaceDualTarget=2,
CircularPowerCell7in=3,
RapidReactCircularCargoBall=4,
StrongholdHighGoal=5,
Apriltag_200mm=6,
Aruco6in_16h5=7,
Apriltag6in_16h5=8
}
export interface PipelineSettings {
offsetRobotOffsetMode: RobotOffsetPointMode
streamingFrameDivisor: number
offsetDualPointBArea: number
contourGroupingMode: number
hsvValue: WebsocketNumberPair | [number, number]
cameraGain: number
cameraBlueGain: number
cameraRedGain: number
cornerDetectionSideCount: number
contourRatio: WebsocketNumberPair | [number, number]
contourTargetOffsetPointEdge: number
pipelineNickname: string
inputImageRotationMode: number
contourArea: WebsocketNumberPair | [number, number]
solvePNPEnabled: boolean
contourFullness: WebsocketNumberPair | [number, number]
pipelineIndex: number
inputShouldShow: boolean
cameraAutoExposure: boolean
contourSpecklePercentage: number
contourTargetOrientation: number
targetModel: TargetModel
cornerDetectionUseConvexHulls: boolean
outputShouldShow: boolean
outputShouldDraw: boolean
offsetDualPointA: {x: number, y: number}
offsetDualPointB: {x: number, y: number}
hsvHue: WebsocketNumberPair | [number, number]
ledMode: boolean
hueInverted: boolean
outputShowMultipleTargets: boolean
contourSortMode: number
cameraExposure: number
offsetSinglePoint: {x: number, y: number}
cameraBrightness: number
offsetDualPointAArea: number
cornerDetectionExactSideCount: boolean
cameraVideoModeIndex: number
cornerDetectionStrategy: number
cornerDetectionAccuracyPercentage: number
hsvSaturation: WebsocketNumberPair | [number, number]
pipelineType: PipelineType
contourIntersection: number
}
export type ConfigurablePipelineSettings = Partial<Omit<PipelineSettings, "offsetDualPointAArea" | "cornerDetectionSideCount" | "pipelineNickname" | "pipelineIndex" | "pipelineType" | "cornerDetectionUseConvexHulls" | "offsetDualPointA" | "offsetDualPointB" | "ledMode" | "offsetSinglePoint" | "offsetDualPointBArea" | "cornerDetectionExactSideCount" | "cornerDetectionStrategy">>
export const DefaultPipelineSettings: PipelineSettings = {
offsetRobotOffsetMode: RobotOffsetPointMode.None,
streamingFrameDivisor: 0,
offsetDualPointBArea: 0,
contourGroupingMode: 0,
hsvValue: { first: 50, second: 255 },
cameraBlueGain: 20,
cameraRedGain: 11,
cornerDetectionSideCount: 4,
contourRatio: { first: 0, second: 20 },
contourTargetOffsetPointEdge: 0,
pipelineNickname: "Placeholder Pipeline",
inputImageRotationMode: 0,
contourArea: { first: 0, second: 100 },
solvePNPEnabled: false,
contourFullness: { first: 0, second: 100 },
pipelineIndex: 0,
inputShouldShow: false,
cameraAutoExposure: false,
contourSpecklePercentage: 5,
contourTargetOrientation: 1,
cornerDetectionUseConvexHulls: true,
outputShouldShow: true,
outputShouldDraw: true,
offsetDualPointA: { x: 0, y: 0 },
offsetDualPointB: { x: 0, y: 0 },
hsvHue: { first: 50, second: 180 },
hueInverted: false,
contourSortMode: 0,
offsetSinglePoint: { x: 0, y: 0 },
cameraBrightness: 50,
offsetDualPointAArea: 0,
cornerDetectionExactSideCount: false,
cameraVideoModeIndex: 0,
cornerDetectionStrategy: 0,
cornerDetectionAccuracyPercentage: 10,
hsvSaturation: { first: 50, second: 255 },
contourIntersection: 1,
// These settings will be overridden by different pipeline types
cameraGain: -1,
targetModel: -1,
ledMode: false,
outputShowMultipleTargets: false,
cameraExposure: -1,
pipelineType: -1
};
export interface ReflectivePipelineSettings extends PipelineSettings {
pipelineType: PipelineType.Reflective
contourFilterRangeY: number
contourFilterRangeX: number
}
export type ConfigurableReflectivePipelineSettings = Partial<Omit<ReflectivePipelineSettings, "pipelineType">> & ConfigurablePipelineSettings
export const DefaultReflectivePipelineSettings: ReflectivePipelineSettings = {
...DefaultPipelineSettings,
cameraGain: 20,
targetModel: TargetModel.InfiniteRechargeHighGoalOuter,
ledMode: true,
outputShowMultipleTargets: false,
cameraExposure: 6,
pipelineType: PipelineType.Reflective,
contourFilterRangeY: 2,
contourFilterRangeX: 2
};
export interface ColoredShapePipelineSettings extends PipelineSettings {
pipelineType: PipelineType.ColoredShape
erode: boolean
cameraCalibration: null
dilate: boolean
circleAccuracy: number
contourRadius: WebsocketNumberPair | [number, number]
circleDetectThreshold: number
accuracyPercentage: number
contourShape: number
contourPerimeter: WebsocketNumberPair | [number, number]
minDist: number
maxCannyThresh: number
}
export type ConfigurableColoredShapePipelineSettings = Partial<Omit<ColoredShapePipelineSettings, "pipelineType" | "erode" | "cameraCalibration" | "dilate" | "minDist" >> & ConfigurablePipelineSettings
export const DefaultColoredShapePipelineSettings: ColoredShapePipelineSettings = {
...DefaultPipelineSettings,
cameraGain: 75,
targetModel: TargetModel.InfiniteRechargeHighGoalOuter,
ledMode: true,
outputShowMultipleTargets: false,
cameraExposure: 20,
pipelineType: PipelineType.ColoredShape,
erode: false,
cameraCalibration: null,
dilate: false,
circleAccuracy: 20,
contourRadius: { first: 0, second: 100 },
circleDetectThreshold: 5,
accuracyPercentage: 10,
contourShape: 2,
contourPerimeter: { first: 0, second: 1.7976931348623157e+308 },
minDist: 20,
maxCannyThresh: 90
};
export interface AprilTagPipelineSettings extends PipelineSettings {
pipelineType: PipelineType.AprilTag
hammingDist: number
numIterations: number
decimate: number
blur: number
decisionMargin: number
refineEdges: boolean
debug: boolean
threads: number
tagFamily: AprilTagFamily
}
export type ConfigurableAprilTagPipelineSettings = Partial<Omit<AprilTagPipelineSettings, "pipelineType" | "hammingDist" | "debug">> & ConfigurablePipelineSettings
export const DefaultAprilTagPipelineSettings: AprilTagPipelineSettings = {
...DefaultPipelineSettings,
cameraGain: 75,
targetModel: TargetModel.Apriltag6in_16h5,
ledMode: false,
outputShowMultipleTargets: true,
cameraExposure: 20,
pipelineType: PipelineType.AprilTag,
hammingDist: 0,
numIterations: 40,
decimate: 1,
blur: 0,
decisionMargin: 35,
refineEdges: true,
debug: false,
threads: 4,
tagFamily: AprilTagFamily.Family16h5
};
export interface ArucoPipelineSettings extends PipelineSettings {
pipelineType: PipelineType.Aruco
decimate: number
threads: number
numIterations: number
cornerAccuracy: number
useAruco3: boolean
}
export type ConfigurableArucoPipelineSettings = Partial<Omit<ArucoPipelineSettings, "pipelineType">> & ConfigurablePipelineSettings
export const DefaultArucoPipelineSettings: ArucoPipelineSettings = {
...DefaultPipelineSettings,
outputShowMultipleTargets: true,
targetModel: TargetModel.Aruco6in_16h5,
cameraExposure: -1,
cameraAutoExposure: true,
ledMode: false,
pipelineType: PipelineType.Aruco,
decimate: 1,
threads: 2,
numIterations: 100,
cornerAccuracy: 25,
useAruco3: true
};
export type ActivePipelineSettings = ReflectivePipelineSettings | ColoredShapePipelineSettings | AprilTagPipelineSettings | ArucoPipelineSettings
export type ActiveConfigurablePipelineSettings = ConfigurableReflectivePipelineSettings | ConfigurableColoredShapePipelineSettings | ConfigurableAprilTagPipelineSettings | ConfigurableArucoPipelineSettings

View File

@@ -0,0 +1,152 @@
import { type ActivePipelineSettings, DefaultAprilTagPipelineSettings } from "@/types/PipelineTypes";
export interface GeneralSettings {
version?: string
gpuAcceleration?: string
hardwareModel?: string
hardwarePlatform?: string
}
export interface MetricData {
cpuTemp?: string,
cpuUtil?: string,
cpuMem?: string,
gpuMem?: string,
ramUtil?: string
gpuMemUtil?: string,
cpuThr?: string,
cpuUptime?: string,
diskUtilPct?: string,
}
export enum NetworkConnectionType {
DHCP = 0,
Static = 1
}
export interface NetworkSettings {
ntServerAddress: string
connectionType: NetworkConnectionType,
staticIp: string,
hostname: string,
runNTServer: boolean
shouldMange: boolean,
networkManagerIface?: string,
physicalInterface?: string,
setStaticCommand?: string,
setDHCPcommand?: string
}
export interface LightingSettings {
supported: boolean,
brightness: number
}
export enum LogLevel {
ERROR=0,
WARN=1,
INFO=2,
DEBUG=3,
TRACE=4
}
export interface LogMessage {
level: LogLevel,
message: string
}
export interface Resolution {
width: number,
height: number
}
export interface VideoFormat {
resolution: Resolution
fps: number,
pixelFormat: string,
index?: number,
diagonalFOV?: number,
horizontalFOV?: number,
verticalFOV?: number,
standardDeviation?: number,
mean?: number
}
export interface CameraCalibrationResult {
resolution: Resolution
distCoeffs: number[],
standardDeviation: number,
perViewErrors: number[],
intrinsics: number[],
}
export interface ConfigurableCameraSettings {
fov: number
}
export interface CameraSettings {
nickname: string
fov: {
value: number,
managedByVendor: boolean
}
stream: {
inputPort: number,
outputPort: number
}
validVideoFormats: VideoFormat[]
completeCalibrations: CameraCalibrationResult[]
lastPipelineIndex?: number,
currentPipelineIndex: number,
pipelineNicknames: string[],
pipelineSettings: ActivePipelineSettings
}
export const PlaceholderCameraSettings: CameraSettings = {
nickname: "Placeholder Camera",
fov: {
value: 70,
managedByVendor: true
},
stream: {
inputPort: 0,
outputPort: 0
},
validVideoFormats: [
{
resolution: { width: 1920, height: 1080 },
fps: 60,
pixelFormat: "RGB"
},
{
resolution: { width: 1280, height: 720 },
fps: 60,
pixelFormat: "RGB"
},
{
resolution: { width: 640, height: 480 },
fps: 30,
pixelFormat: "RGB"
}
],
completeCalibrations: [],
pipelineNicknames: ["Placeholder Pipeline"],
lastPipelineIndex: 0,
currentPipelineIndex: 0,
pipelineSettings: DefaultAprilTagPipelineSettings
};
export enum CalibrationBoardTypes {
Chessboard=0,
DotBoard=1
}
export enum RobotOffsetType {
Clear=0,
Single=1,
DualFirst=2,
DualSecond=3
}

View File

@@ -0,0 +1,118 @@
import type { GeneralSettings, LightingSettings, MetricData, NetworkSettings } from "@/types/SettingTypes";
import type { ActivePipelineSettings } from "@/types/PipelineTypes";
import type { LogLevel } from "@/types/SettingTypes";
export interface WebsocketLogMessage {
logMessage: {
logLevel: LogLevel,
logMessage: string
}
}
export interface WebsocketSettingsUpdate {
general: Required<GeneralSettings>,
lighting: Required<LightingSettings>,
networkSettings: NetworkSettings
}
export interface WebsocketNumberPair {
first: number,
second: number
}
export interface WebsocketCompleteCalib {
distCoeffs: number[],
height: number,
width: number,
standardDeviation: number,
perViewErrors: number[],
intrinsics: number[]
}
export type WebsocketVideoFormat = Record<number, {
fps: number,
height: number,
width: number,
pixelFormat: string,
index?: number,
diagonalFOV?: number,
horizontalFOV?: number,
verticalFOV?: number,
standardDeviation?: number,
mean?: number
}>
export interface WebsocketCameraSettingsUpdate {
calibrations: WebsocketCompleteCalib[],
currentPipelineIndex: number,
currentPipelineSettings: ActivePipelineSettings,
fov: number,
inputStreamPort: number,
isFovConfigurable: boolean,
nickname: string,
outputStreamPort: number,
pipelineNicknames: string[],
videoFormatList: WebsocketVideoFormat
}
export interface WebsocketNTUpdate {
connected: boolean,
address?: string,
clients?: number
}
export type WebsocketPipelineResultUpdate = Record<number, {
fps: number,
latency: number,
targets: {
yaw: number,
pitch: number,
skew: number,
area: number,
ambiguity: number,
fiducialId: number,
pose: {
"angle_z": number,
"qw": number,
"qx": number,
"x": number,
"qy": number,
"y": number,
"qz": number,
"z": number
},
}[]
}>
export interface WebsocketCalibrationData {
"patternWidth": number,
"boardType": number,
"hasEnough": boolean,
"count": number,
"minCount": number,
"videoModeIndex": number,
"patternHeight": number,
"squareSizeIn": number
}
export interface IncomingWebsocketData {
log?: WebsocketLogMessage,
settings?: WebsocketSettingsUpdate,
cameraSettings?: WebsocketCameraSettingsUpdate[],
ntConnectionInfo?: WebsocketNTUpdate,
metrics?: Required<MetricData>,
updatePipelineResult?: WebsocketPipelineResultUpdate,
networkInfo?: {
possibleRios: string[],
deviceips: string[]
}
mutatePipelineSettings?: Partial<ActivePipelineSettings>,
cameraIndex?: number // Sent when mutating pipeline settings to check against currently active
calibrationData?: WebsocketCalibrationData,
}
export enum WebsocketPipelineType {
Calib3d=-2,
DriverMode=-1,
Reflective=0,
ColoredShape=1,
AprilTag=2,
Aruco=3
}

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import CamerasCard from "@/components/cameras/CameraSettingsCard.vue";
import CalibrationCard from "@/components/cameras/CameraCalibrationCard.vue";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { computed } from "vue";
import CamerasView from "@/components/cameras/CamerasView.vue";
import { useStateStore } from "@/stores/StateStore";
const cameraViewType = computed<number[]>({
get: (): number[] => {
// Only show the input stream in Color Picking Mode
if(useStateStore().colorPickingMode) return [0];
// Only show the output stream in Driver Mode or Calibration Mode
if(useCameraSettingsStore().isDriverMode || useCameraSettingsStore().isCalibrationMode) return [1];
const ret: number[] = [];
if(useCameraSettingsStore().currentPipelineSettings.inputShouldShow) {
ret.push(0);
}
if(useCameraSettingsStore().currentPipelineSettings.outputShouldShow) {
ret.push(1);
}
if(ret.length === 0) return [0];
return ret;
},
set: v => {
useCameraSettingsStore().currentPipelineSettings.inputShouldShow = v.includes(0);
useCameraSettingsStore().currentPipelineSettings.outputShouldShow = v.includes(1);
useCameraSettingsStore().changeCurrentPipelineSetting({ inputShouldShow: v.includes(0) }, false);
}
});
</script>
<template>
<div>
<v-row
no-gutters
class="pa-3"
>
<v-col
cols="12"
md="7"
>
<CamerasCard />
<CalibrationCard />
</v-col>
<v-col
class="pl-md-3 pt-3 pt-md-0"
cols="12"
md="5"
>
<CamerasView v-model="cameraViewType" />
</v-col>
</v-row>
</div>
</template>

View File

@@ -1,833 +0,0 @@
<template>
<div>
<v-row
no-gutters
class="pa-3"
>
<v-col
cols="12"
md="7"
>
<!-- Camera card -->
<v-card
class="mb-3 pr-6 pb-3"
color="primary"
dark
>
<v-card-title>Camera Settings</v-card-title>
<div class="ml-5">
<CVselect
v-model="currentCameraIndex"
name="Camera"
:list="$store.getters.cameraList"
:select-cols="$vuetify.breakpoint.mdAndUp ? 10 : 7"
@input="handleInput('currentCamera',currentCameraIndex)"
/>
<CVnumberinput
v-model="cameraSettings.fov"
:tooltip="cameraSettings.isFovConfigurable ? 'Field of view (in degrees) of the camera measured across the diagonal of the frame, in a video mode which covers the whole sensor area.' : 'This setting is managed by a vendor'"
name="Maximum Diagonal FOV"
:disabled="!cameraSettings.isFovConfigurable"
:label-cols="$vuetify.breakpoint.mdAndUp ? undefined : 7"
/>
<br>
<v-btn
style="margin-top:10px"
small
color="secondary"
@click="sendCameraSettings"
>
<v-icon left>
mdi-content-save
</v-icon>
Save Camera Settings
</v-btn>
</div>
</v-card>
<!-- Calibration card -->
<v-card
class="pr-6 pb-3"
color="primary"
dark
>
<v-card-title>Camera Calibration</v-card-title>
<div class="ml-5">
<v-row>
<!-- Calibration input -->
<v-col
cols="12"
md="6"
>
<v-form
ref="form"
v-model="settingsValid"
>
<CVselect
v-model="selectedFilteredResIndex"
name="Resolution"
select-cols="7"
:list="stringResolutionList"
:disabled="isCalibrating"
tooltip="Resolution to calibrate at (you will have to calibrate every resolution you use 3D mode on)"
/>
<CVselect
v-model="streamingFrameDivisor"
name="Decimation"
tooltip="Resolution to which camera frames are downscaled for detection. Calibration still uses full-res"
:list="calibrationDivisors"
select-cols="7"
@rollback="e => rollback('streamingFrameDivisor', e)"
/>
<CVselect
v-model="boardType"
name="Board Type"
select-cols="7"
:list="['Chessboard', 'Dot Grid']"
:disabled="isCalibrating"
tooltip="Calibration board pattern to use"
/>
<CVnumberinput
v-model="squareSizeIn"
name="Pattern Spacing (in)"
tooltip="Spacing between pattern features in inches"
:disabled="isCalibrating"
:rules="[v => (v > 0) || 'Size must be positive']"
:label-cols="$vuetify.breakpoint.mdAndUp ? 5 : 7"
/>
<CVnumberinput
v-model="boardWidth"
name="Board Width"
tooltip="Width of the board in dots or chessboard squares"
:disabled="isCalibrating"
:rules="[v => (v >= 4) || 'Width must be at least 4']"
:label-cols="$vuetify.breakpoint.mdAndUp ? 5 : 7"
/>
<CVnumberinput
v-model="boardHeight"
name="Board Height"
tooltip="Height of the board in dots or chessboard squares"
:disabled="isCalibrating"
:rules="[v => (v >= 4) || 'Height must be at least 4']"
:label-cols="$vuetify.breakpoint.mdAndUp ? 5 : 7"
/>
</v-form>
</v-col>
<!-- Calibrated table -->
<v-col
cols="12"
md="6"
>
<v-row
align="start"
class="pb-4"
>
<v-simple-table
fixed-header
height="100%"
dense
>
<thead style="font-size: 1.25rem;">
<tr>
<th class="text-center">
<tooltipped-label text="Resolution" />
</th>
<th class="text-center">
<tooltipped-label
tooltip="Average reprojection error of the calibration, in pixels"
text="Mean Error"
/>
</th>
<th class="text-center">
<tooltipped-label
tooltip="Standard deviation of the mean error, in pixels"
text="Standard Deviation"
/>
</th>
<th class="text-center">
<tooltipped-label
tooltip="Estimated Horizontal FOV, in degrees"
text="Horizontal FOV"
/>
</th>
<th class="text-center">
<tooltipped-label
tooltip="Estimated Vertical FOV, in degrees"
text="Vertical FOV"
/>
</th>
<th class="text-center">
<tooltipped-label
tooltip="Estimated Diagonal FOV, in degrees"
text="Diagonal FOV"
/>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(value, index) in filteredResolutionList"
:key="index"
>
<td> {{ value.width }} X {{ value.height }}</td>
<td>
{{ isCalibrated(value) ? value.mean.toFixed(2) + "px" : "—" }}
</td>
<td> {{ isCalibrated(value) ? value.standardDeviation.toFixed(2) + "px" : "—" }} </td>
<td> {{ isCalibrated(value) ? value.horizontalFOV.toFixed(2) + "°" : "—" }} </td>
<td> {{ isCalibrated(value) ? value.verticalFOV.toFixed(2) + "°" : "—" }} </td>
<td> {{ isCalibrated(value) ? value.diagonalFOV.toFixed(2) + "°" : "—" }} </td>
</tr>
</tbody>
</v-simple-table>
</v-row>
<v-row justify="center">
<v-chip
v-show="isCalibrating"
label
:color="snapshotAmount < 25 ? 'grey' : 'secondary'"
>
Snapshots: {{ snapshotAmount }} of at least {{ minSnapshots }}
</v-chip>
</v-row>
</v-col>
</v-row>
<v-row v-if="isCalibrating">
<v-col
cols="12"
class="pt-0"
>
<CVslider
v-model="$store.getters.currentPipelineSettings.cameraExposure"
:disabled="$store.getters.currentPipelineSettings.cameraAutoExposure"
name="Exposure"
:min="0"
:max="100"
slider-cols="8"
step="0.1"
tooltip="Directly controls how much light is allowed to fall onto the sensor, which affects apparent brightness"
@input="e => handlePipelineUpdate('cameraExposure', e)"
/>
<CVslider
v-model="$store.getters.currentPipelineSettings.cameraBrightness"
name="Brightness"
:min="0"
:max="100"
slider-cols="8"
@input="e => handlePipelineUpdate('cameraBrightness', e)"
/>
<CVswitch
v-model="$store.getters.currentPipelineSettings.cameraAutoExposure"
class="pt-2"
name="Auto Exposure"
tooltip="Enables or Disables camera automatic adjustment for current lighting conditions"
@input="e => handlePipelineUpdate('cameraAutoExposure', e)"
/>
<CVslider
v-if="cameraGain >= 0"
v-model="cameraGain"
name="Camera Gain"
min="0"
max="100"
tooltip="Controls camera gain, similar to brightness"
:slider-cols="largeBox"
@input="handlePipelineData('cameraGain')"
@rollback="e => rollback('cameraGain', e)"
/>
<CVslider
v-if="$store.getters.currentPipelineSettings.cameraRedGain !== -1"
v-model="$store.getters.currentPipelineSettings.cameraRedGain"
name="Red AWB Gain"
min="0"
max="100"
tooltip="Controls red automatic white balance gain, which affects how the camera captures colors in different conditions"
:slider-cols="8"
@input="e => handlePipelineData('cameraRedGain', e)"
/>
<CVslider
v-if="$store.getters.currentPipelineSettings.cameraBlueGain !== -1"
v-model="$store.getters.currentPipelineSettings.cameraBlueGain"
name="Blue AWB Gain"
min="0"
max="100"
tooltip="Controls blue automatic white balance gain, which affects how the camera captures colors in different conditions"
:slider-cols="8"
@input="e => handlePipelineData('cameraBlueGain', e)"
/>
</v-col>
</v-row>
<v-row>
<v-col align-self="center">
<v-btn
small
color="secondary"
style="width: 100%;"
:disabled="disallowCalibration"
@click="sendCalibrationMode"
>
{{ isCalibrating ? "Take Snapshot" : "Start Calibration" }}
</v-btn>
</v-col>
<v-col align-self="center">
<v-btn
small
:color="hasEnough ? 'accent' : 'red'"
:class="hasEnough ? 'black--text' : 'white---text'"
style="width: 100%;"
:disabled="checkCancellation"
@click="sendCalibrationFinish"
>
{{ hasEnough ? "Finish Calibration" : "Cancel Calibration" }}
</v-btn>
</v-col>
<v-col>
<v-btn
color="accent"
small
outlined
style="width: 100%;"
:disabled="!settingsValid"
@click="downloadBoard"
>
<v-icon left>
mdi-download
</v-icon>
Download Calibration Target
</v-btn>
</v-col>
<v-col>
<v-btn
color="secondary"
:disabled="isCalibrating"
small
style="width: 100%;"
@click="$refs.importCalibrationFromCalibdb.click()"
>
<v-icon left>
mdi-upload
</v-icon>
Import From CalibDB
</v-btn>
</v-col>
</v-row>
</div>
</v-card>
</v-col>
<v-col
class="pl-md-3 pt-3 pt-md-0"
cols="12"
md="5"
>
<template>
<CVimage
:id="'cameras-cal'"
:idx="1"
:disconnected="!$store.state.backendConnected"
scale="100"
style="border-radius: 5px;"
/>
<v-dialog
v-model="calibrationDialog"
width="500px"
:persistent="true"
>
<v-card
color="primary"
dark
>
<v-card-title> Camera Calibration </v-card-title>
<div
class="ml-3"
>
<v-col align="center">
<template v-if="calibrationInProgress && !calibrationFailed">
<v-progress-circular
indeterminate
:size="70"
:width="8"
color="accent"
/>
<v-card-text>Camera is being calibrated. This process may take several minutes...</v-card-text>
</template>
<template v-else-if="!calibrationFailed">
<v-icon
color="green"
size="70"
>
mdi-check-bold
</v-icon>
<v-card-text>Camera has been successfully calibrated at {{ stringResolutionList[selectedFilteredResIndex] }}!</v-card-text>
</template>
<template v-else>
<v-icon
color="red"
size="70"
>
mdi-close
</v-icon>
<v-card-text>Camera calibration failed! Make sure that the photos are taken such that the rainbow grid circles align with the corners of the chessboard, and try again. More information is available in the program logs.</v-card-text>
</template>
</v-col>
</div>
<v-card-actions>
<v-spacer />
<v-btn
v-if="!calibrationInProgress || calibrationFailed"
color="white"
text
@click="closeDialog"
>
OK
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
</v-col>
</v-row>
<!-- Special hidden upload input that gets 'clicked' when the user imports calibdb data -->
<input
ref="importCalibrationFromCalibdb"
type="file"
accept=".json"
style="display: none;"
@change="readImportedCalibration"
>
<v-snackbar
v-model="snack"
top
:color="snackbar.color"
timeout="2000"
>
<span>{{ snackbar.text }}</span>
</v-snackbar>
</div>
</template>
<script>
import CVselect from '../components/common/cv-select';
import CVnumberinput from '../components/common/cv-number-input';
import CVslider from '../components/common/cv-slider';
import CVswitch from '../components/common/cv-switch';
import CVimage from "../components/common/cv-image";
import TooltippedLabel from "../components/common/cv-tooltipped-label";
import jsPDF from "jspdf";
import "../jsPDFFonts/Prompt-Regular-normal.js";
export default {
name: 'Cameras',
components: {
TooltippedLabel,
CVselect,
CVnumberinput,
CVslider,
CVswitch,
CVimage
},
data() {
return {
calibrationDialog: false,
calibrationInProgress: false,
calibrationFailed: false,
filteredVideomodeIndex: 0,
settingsValid: true,
unfilteredStreamDivisors: [1, 2, 4],
snackbar: {
color: "success",
text: "",
},
snack: false,
}
},
computed: {
disallowCalibration() {
return !(this.calibrationData.boardType === 0 || this.calibrationData.boardType === 1) || !this.settingsValid;
},
checkCancellation() {
if (this.isCalibrating) {
return false
} else if (this.disallowCalibration) {
return true;
} else {
return true
}
},
currentCameraIndex: {
get() {
return this.$store.state.currentCameraIndex;
},
set(value) {
this.$store.commit('currentCameraIndex', value);
}
},
cameraGain: {
get() {
return parseInt(this.$store.getters.currentPipelineSettings.cameraGain)
},
set(val) {
this.$store.commit("mutatePipeline", {"cameraGain": parseInt(val)});
}
},
calibrationDivisors: {
get() {
return this.unfilteredStreamDivisors.filter(item => {
const res = this.stringResolutionList[this.selectedFilteredResIndex].split(" X ").map(it => parseInt(it));
// Realistically, we need more than 320x240, but lower than this is
// basically unusable. For now, don't allow decimations that take us
// below that
const ret = ((res[0] / item) >= 300 && (res[1] / item) >= 220) || (item === 1);
return ret;
})
}
},
// Makes sure there's only one entry per resolution
filteredResolutionList: {
get() {
let list = this.$store.getters.videoFormatList;
let filtered = [];
list.forEach((it, i) => {
if (!filtered.some(e => e.width === it.width && e.height === it.height)) {
it['index'] = i;
const calib = this.getCalibrationCoeffs(it);
if (calib != null) {
it['standardDeviation'] = calib.standardDeviation;
it['mean'] = calib.perViewErrors.reduce((a, b) => a + b) / calib.perViewErrors.length;
it['horizontalFOV'] = 2 * Math.atan2(it.width/2,calib.intrinsics[0]) * (180/Math.PI);
it['verticalFOV'] = 2 * Math.atan2(it.height/2,calib.intrinsics[4]) * (180/Math.PI);
it['diagonalFOV'] = 2 * Math.atan2(Math.sqrt(it.width**2 + (it.height/(calib.intrinsics[4]/calib.intrinsics[0]))**2)/2,calib.intrinsics[0]) * (180/Math.PI);
}
filtered.push(it);
}
});
filtered.sort((a, b) => (b.width + b.height) - (a.width + a.height));
return filtered
}
},
stringResolutionList: {
get() {
return this.filteredResolutionList.map(res => `${res['width']} X ${res['height']}`);
}
},
cameraSettings: {
get() {
return this.$store.getters.currentCameraSettings;
},
set(value) {
this.$store.commit('cameraSettings', value);
}
},
streamingFrameDivisor: {
get() {
return this.$store.getters.currentPipelineSettings.streamingFrameDivisor;
},
set(val) {
this.$store.commit("mutatePipeline", {"streamingFrameDivisor": val});
this.handlePipelineUpdate("streamingFrameDivisor", val);
}
},
boardType: {
get() {
return this.calibrationData.boardType
},
set(value) {
this.$store.commit('mutateCalibrationState', {['boardType']: value});
}
},
snapshotAmount: {
get() {
return this.calibrationData.count
}
},
minSnapshots: {
get() {
return this.calibrationData.minCount
}
},
hasEnough: {
get() {
return this.calibrationData.hasEnough
}
},
boardWidth: {
get() {
return this.calibrationData.patternWidth
},
set(value) {
this.$store.commit('mutateCalibrationState', {['patternWidth']: value})
}
},
boardHeight: {
get() {
return this.calibrationData.patternHeight
},
set(value) {
this.$store.commit('mutateCalibrationState', {['patternHeight']: value})
}
},
squareSizeIn: {
get() {
return this.calibrationData.squareSizeIn
},
set(value) {
this.$store.commit('mutateCalibrationState', {['squareSizeIn']: value})
}
},
calibrationData: {
get() {
return this.$store.state.calibrationData
}
},
isCalibrating: {
get() {
return this.$store.getters.currentPipelineIndex === -2;
}
},
selectedFilteredResIndex: {
get() {
return this.filteredVideomodeIndex
},
set(i) {
console.log(`Setting filtered index to ${i}`);
this.filteredVideomodeIndex = i;
this.$store.commit('mutateCalibrationState', {['videoModeIndex']: this.filteredResolutionList[i].index});
}
},
},
methods: {
readImportedCalibration(event) {
// let formData = new FormData();
// formData.append("zipData", event.target.files[0]);
const filename = event.target.files[0].name;
event.target.files[0].text().then(fileText => {
const data = {
"cameraIndex": this.$store.getters.currentCameraIndex,
"payload": fileText,
"filename": filename,
};
this.axios
.post("http://" + this.$address + "/api/calibration/importFromCalibDB", data, {
headers: { "Content-Type": "text/plain" },
})
.then((response) => {
this.snackbar = {
color: response.status === 200 ? "success" : "error",
text: response.data.text || response.data
}
this.snack = true;
})
.catch((err) => {
if (err.request) {
this.snackbar = {
color: "error",
text: "Error while uploading calibration file! The backend didn't respond to the upload attempt.",
};
} else {
this.snackbar = {
color: "error",
text: "Error while uploading calibration file!",
};
}
this.snack = true;
});
})
},
closeDialog() {
this.calibrationDialog = false;
this.calibrationInProgress = false;
this.calibrationFailed = false;
},
getCalibrationCoeffs(resolution) {
const calList = this.$store.getters.calibrationList;
let ret = null;
calList.forEach(cal => {
if (cal.width === resolution.width && cal.height === resolution.height) {
ret = cal
}
});
return ret;
},
downloadBoard() {
const config = {
type: this.boardType === 0 ? "chessboard" : "dotgrid",
boardWidthIn: this.boardWidth,
boardHeightIn: this.boardHeight,
patternSpacingIn: this.squareSizeIn
}
const doc = new jsPDF({ unit: "in", format: "letter" })
doc.setFont("Prompt-Regular")
doc.setFontSize(12)
const paperWidth = 8.5
const paperHeight = 11.0
// Draw the selected pattern to the document
switch (config.type) {
case "chessboard":
// eslint-disable-next-line no-case-declarations
const chessboardStartX = (paperWidth - config.boardWidthIn * config.patternSpacingIn) / 2
// eslint-disable-next-line no-case-declarations
const chessboardStartY = (paperHeight - config.boardWidthIn * config.patternSpacingIn) / 2
for (let squareY = 0; squareY < config.boardHeightIn; squareY++) {
for (let squareX = 0; squareX < config.boardWidthIn; squareX++) {
const xPos = chessboardStartX + squareX * config.patternSpacingIn
const yPos = chessboardStartY + squareY * config.patternSpacingIn
// Only draw the odd squares to create the chessboard pattern
if ((xPos + yPos + 0.25) % 2 === 0) {
doc.rect(xPos, yPos, config.patternSpacingIn, config.patternSpacingIn, "F")
}
}
}
break
case "dotgrid":
// eslint-disable-next-line no-case-declarations
const dotgridStartX = (paperWidth - (2 * (config.boardWidthIn - 1) + ((config.boardHeightIn - 1) % 2)) * config.patternSpacingIn) / 2.0
// eslint-disable-next-line no-case-declarations
const dotgridStartY = (paperHeight - (config.boardHeightIn - config.patternSpacingIn)) / 2
for (let squareY = 0; squareY < config.boardHeightIn; squareY++) {
for (let squareX = 0; squareX < config.boardWidthIn; squareX++) {
const xPos = dotgridStartX + (2 * squareX + (squareY % 2)) * config.patternSpacingIn
const yPos = dotgridStartY + squareY * config.patternSpacingIn
doc.circle(xPos, yPos, config.patternSpacingIn / 4, "F")
}
}
break
}
// Draw ruler pattern
const lineStartX = 1.0
const lineEndX = paperWidth - lineStartX
const lineY = paperHeight - 1.0
doc.setLineWidth(0.01)
doc.line(lineStartX, lineY, lineEndX, lineY)
for (let tickX = lineStartX; tickX <= lineEndX; tickX++) {
doc.line(tickX, lineY, tickX, lineY + 0.25)
doc.text(`${tickX - 1}${tickX - 1 === 0 ? " in" : ""}`, tickX + 0.1, lineY + 0.25)
}
// Add branding
const logoImage = new Image();
logoImage.src = require('@/assets/logos/logoMono.png');
doc.addImage(logoImage, 'PNG', 1.0, 0.75, 1.4, 0.5);
doc.text(`${config.boardWidthIn} x ${config.boardHeightIn} | ${config.patternSpacingIn}in`, paperWidth - 1, 1.0,
{
maxWidth: (paperWidth - 2.0) / 2,
align: "right",
}
)
doc.save(`calibrationTarget-${config.type}.pdf`)
},
sendCameraSettings() {
this.axios.post("http://" + this.$address + "/api/settings/camera", {"settings": this.cameraSettings, "index": this.$store.state.currentCameraIndex})
.then(response => {
this.snackbar = {
color: "success",
text: response.data.text || response.data
}
this.snack = true;
})
.catch(error => {
if(error.response) {
this.snackbar = {
color: "error",
text: error.response.data.text || error.response.data
}
} else if(error.request) {
this.snackbar = {
color: "error",
text: "Error while trying to process the request! The backend didn't respond.",
};
} else {
this.snackbar = {
color: "error",
text: "An error occurred while trying to process the request.",
};
}
this.snack = true;
})
},
isCalibrated(resolution) {
return this.$store.getters.currentCameraSettings.calibrations
.some(e => e.width === resolution.width && e.height === resolution.height);
},
sendCalibrationMode() {
let data = {
['cameraIndex']: this.$store.state.currentCameraIndex
};
if (this.isCalibrating) {
data['takeCalibrationSnapshot'] = true
} else {
// This store prevents an edge case of a user not selecting a different resolution, which causes the set logic to not be called
this.$store.commit('mutateCalibrationState', {['videoModeIndex']: this.filteredResolutionList[this.selectedFilteredResIndex].index});
const calData = this.calibrationData;
calData.isCalibrating = true;
data['startPnpCalibration'] = calData;
console.log("starting calibration with index " + calData.videoModeIndex);
}
this.$store.commit('currentPipelineIndex', -2);
this.$store.state.websocket.ws.send(this.$msgPack.encode(data));
},
sendCalibrationFinish() {
console.log("finishing calibration for index " + this.$store.getters.currentCameraIndex);
this.calibrationDialog = true;
this.calibrationInProgress = true;
this.axios.post("http://" + this.$address + "/api/calibration/end", {index: this.$store.getters.currentCameraIndex})
.then(() => {
// End calibration will always return a 200 code on success
this.calibrationInProgress = false;
}
).catch(() => {
this.calibrationFailed = true;
});
}
}
}
</script>
<style>
::-webkit-scrollbar{
height: 0.55em;
}
</style>
<style scoped>
.v-data-table {
text-align: center;
background-color: transparent !important;
width: 100%;
height: 100%;
overflow-y: auto;
}
.v-data-table th {
background-color: #006492 !important;
}
.v-data-table th, td {
font-size: 1rem !important;
}
</style>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed } from "vue";
import CamerasCard from "@/components/dashboard/CamerasCard.vue";
import CameraAndPipelineSelectCard from "@/components/dashboard/CameraAndPipelineSelectCard.vue";
import StreamConfigCard from "@/components/dashboard/StreamConfigCard.vue";
import PipelineConfigCard from "@/components/dashboard/ConfigOptions.vue";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { useStateStore } from "@/stores/StateStore";
const cameraViewType = computed<number[]>({
get: (): number[] => {
// Only show the input stream in Color Picking Mode
if(useStateStore().colorPickingMode) return [0];
// Only show the output stream in Driver Mode or Calibration Mode
if(useCameraSettingsStore().isDriverMode || useCameraSettingsStore().isCalibrationMode) return [1];
const ret: number[] = [];
if(useCameraSettingsStore().currentPipelineSettings.inputShouldShow) {
ret.push(0);
}
if(useCameraSettingsStore().currentPipelineSettings.outputShouldShow) {
ret.push(1);
}
if(ret.length === 0) return [0];
return ret;
},
set: v => {
useCameraSettingsStore().changeCurrentPipelineSetting({
inputShouldShow: v.includes(0),
outputShouldShow: v.includes(1)
}, true);
}
});
</script>
<template>
<v-container
class="pa-3"
fluid
>
<v-row
no-gutters
align="center"
justify="center"
>
<v-col
cols="12"
class="pb-3 pr-lg-3"
lg="8"
align-self="stretch"
>
<CamerasCard v-model="cameraViewType" />
</v-col>
<v-col
cols="12"
class="pb-3"
lg="4"
style="display: flex; flex-direction: column"
align-self="stretch"
>
<CameraAndPipelineSelectCard />
<StreamConfigCard v-model="cameraViewType" />
</v-col>
</v-row>
<PipelineConfigCard />
</v-container>
</template>

View File

@@ -1,12 +1,19 @@
<script setup lang="ts">
const devMode = process.env.NODE_ENV === "development";
</script>
<template>
<div
style="overflow:hidden; height:100%; width:100%"
>
<div
v-if="() => process.env.NODE_ENV === 'development'"
v-if="devMode"
style="width: 100%; height: 100%; padding: 16px"
>
<span style="color: white; font-weight: bold">PhotonClient is in development mode so the documentation page will not load. Please recompile in production mode with the documentation copied over after a full build.</span>
<span style="color: white; font-weight: bold">
PhotonClient is in development mode so the documentation page will not load.
Please recompile in production mode with the documentation copied over after a full build.
</span>
</div>
<div
v-else

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import MetricsCard from "@/components/settings/MetricsCard.vue";
import DeviceControlCard from "@/components/settings/DeviceControlCard.vue";
import NetworkingCard from "@/components/settings/NetworkingCard.vue";
import LightingControlCard from "@/components/settings/LEDControlCard.vue";
import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
</script>
<template>
<div class="pa-3">
<MetricsCard />
<DeviceControlCard />
<NetworkingCard />
<LightingControlCard v-if="useSettingsStore().lighting.supported" />
</div>
</template>

View File

@@ -1,140 +0,0 @@
<template>
<v-card
dark
class="pt-3"
color="primary"
flat
>
<v-card-title>
View Program Logs
<v-btn
color="secondary"
style="margin-left: auto;"
depressed
@click="$refs.exportLogFile.click()"
>
<v-icon left>
mdi-download
</v-icon>
Download Log
<!-- Special hidden link that gets 'clicked' when the user exports journalctl logs -->
<a
ref="exportLogFile"
style="color: black; text-decoration: none; display: none"
:href="'http://' + this.$address + '/api/utils/logs/photonvision-journalctl.txt'"
download="photonvision-journalctl.txt"
/>
</v-btn>
</v-card-title>
<div class="pr-6 pl-6">
<v-btn-toggle
v-model="logLevel"
dark
multiple
class="fill mb-4"
>
<v-btn
v-for="(level) in possibleLevelArray"
:key="level"
color="secondary"
class="fill"
>
{{ level }}
</v-btn>
</v-btn-toggle>
<!-- Logs -->
<v-virtual-scroll
:items="logMessageArray"
item-height="50"
height="600"
>
<template v-slot="{ item }">
<div :class="[getColor(item) + '--text', 'log-item']">
{{ item.message }}
</div>
</template>
</v-virtual-scroll>
</div>
<v-divider />
<v-card-actions>
<v-spacer />
<v-btn
color="white"
text
@click="$store.state.logsOverlay = false"
>
Close
</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
export default {
name: "Logs",
components: {
},
data() {
return {
selectedLevel: [0, 1, 2],
possibleLevelArray: ['ERROR', 'WARN', 'INFO', 'DEBUG'],
colorArray: ['red', 'yellow','green', 'white'],
}
},
computed: {
rawLogs() {
return this.$store.state.logMessages;
},
logMessageArray() {
const logArray = this.$store.state.logMessages;
return logArray.filter(it => this.selectedLevel.includes(it.level));
},
logLevel: {
get() {
return this.selectedLevel
},
set(value) {
this.selectedLevel = value;
}
}
},
methods: {
getColor(message) {
return this.colorArray[message.level];
},
download(filename, text) {
const element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
}
}
</script>
<style scoped>
.v-btn-toggle.fill {
width: 100%;
height: 100%;
}
.v-btn-toggle.fill > .v-btn {
width: 25%;
height: 100%;
}
</style>

View File

@@ -9,20 +9,13 @@
</v-card-subtitle>
</div>
<img
src="../assets/NotFound.webp"
src="@/assets/images/notfound.webp"
alt="missing-page"
style="margin-top: 64px"
>
</div>
</template>
<script>
export default {
name: 'NotFound'
}
</script>
<style>
.not-found-container {
width: 100%;

View File

@@ -1,532 +0,0 @@
<template>
<div>
<v-container
class="pa-3"
fluid
>
<v-row
no-gutters
align="center"
justify="center"
>
<v-col
cols="12"
:class="['pb-3 ', 'pr-lg-3']"
lg="8"
align-self="stretch"
>
<v-card
color="primary"
height="100%"
style="display: flex; flex-direction: column"
dark
>
<v-card-title
class="pb-0 mb-0 pl-4 pt-1"
style="min-height: 50px; justify-content: space-between; align-content: center"
>
<div class="pt-2">
<span class="mr-4">Cameras</span>
<v-chip
label
:color="fpsTooLow ? 'error' : 'transparent'"
:text-color="fpsTooLow ? '#C7EA46' : '#ff4d00'"
style="font-size: 1rem; padding: 0; margin: 0"
>
<span class="pr-1">Processing @ {{ Math.round($store.state.pipelineResults.fps) }}&nbsp;FPS &ndash;</span>
<span v-if="fpsTooLow && !$store.getters.currentPipelineSettings.inputShouldShow && $store.getters.pipelineType === 2">HSV thresholds are too broad; narrow them for better performance</span>
<span v-else-if="fpsTooLow && $store.getters.currentCameraSettings.inputShouldShow">stop viewing the raw stream for better performance</span>
<span v-else>{{ Math.min(Math.round($store.state.pipelineResults.latency), 9999) }} ms latency</span>
</v-chip>
</div>
<div>
<v-switch
v-model="driverMode"
label="Driver Mode"
style="margin-left: auto;"
color="accent"
class="pt-2"
/>
</div>
</v-card-title>
<v-divider />
<v-row
align="center"
class="pl-3 pr-3"
>
<v-col
v-for="idx in (selectedOutputs instanceof Array ? selectedOutputs : [selectedOutputs])"
:key="idx"
cols="12"
:md="selectedOutputs.length === 1 ? 12 : Math.floor(12 / selectedOutputs.length)"
style="height: 100%;"
>
<div style="position: relative; width: 100%; height: 100%;">
<cv-image
:id="idx === 0 ? 'raw-stream' : 'processed-stream'"
ref="streams"
:idx="idx"
:disconnected="!$store.state.backendConnected"
scale="95"
:max-height="$store.getters.isDriverMode ? '40vh' : '300px'"
:max-height-md="$store.getters.isDriverMode ? '50vh' : '380px'"
:max-height-lg="$store.getters.isDriverMode ? '55vh' : '390px'"
:max-height-xl="$store.getters.isDriverMode ? '60vh' : '450px'"
:alt="idx === 0 ? 'Raw stream' : 'Processed stream'"
:color-picking="$store.state.colorPicking && idx === 0"
style="padding-top: 22px;"
@click="onImageClick"
/>
</div>
</v-col>
</v-row>
</v-card>
</v-col>
<v-col
cols="12"
class="pb-3"
lg="4"
style="display: flex; flex-direction: column"
>
<v-card
color="primary"
>
<camera-and-pipeline-select />
</v-card>
<v-card
:disabled="$store.getters.isDriverMode || $store.state.colorPicking"
class="mt-3"
color="primary"
>
<v-row
align="center"
class="pa-sm-3"
>
<v-col lg="12">
<p style="color: white;">
Processing Mode
</p>
<v-btn-toggle
v-model="processingMode"
mandatory
dark
class="fill"
>
<v-btn
color="secondary"
>
<v-icon>mdi-square-outline</v-icon>
<span>2D</span>
</v-btn>
<v-btn
color="secondary"
@click="on3DClick"
>
<v-icon>mdi-cube-outline</v-icon>
<span>3D</span>
</v-btn>
</v-btn-toggle>
</v-col>
<v-col lg="12">
<p style="color: white;">
Stream Display
</p>
<v-btn-toggle
v-model="selectedOutputs"
:multiple="$vuetify.breakpoint.mdAndUp"
mandatory
dark
class="fill"
>
<v-btn
color="secondary"
class="fill"
>
<v-icon>mdi-import</v-icon>
<span>Raw</span>
</v-btn>
<v-btn
color="secondary"
class="fill"
>
<v-icon>mdi-export</v-icon>
<span>Processed</span>
</v-btn>
</v-btn-toggle>
</v-col>
</v-row>
</v-card>
</v-col>
</v-row>
<v-row no-gutters>
<v-col
v-for="(tabs, idx) in tabGroups"
:key="idx"
:cols="Math.floor(12 / tabGroups.length)"
:class="idx !== tabGroups.length - 1 ? 'pr-3' : ''"
align-self="stretch"
>
<v-card
color="primary"
height="100%"
class="pr-4 pl-4"
>
<v-tabs
v-if="!$store.getters.isDriverMode"
v-model="selectedTabs[idx]"
grow
background-color="primary"
dark
height="48"
slider-color="accent"
>
<v-tab
v-for="(tab, i) in tabs"
:key="i"
>
{{ tab.name }}
</v-tab>
</v-tabs>
<div class="pl-4 pr-4 pt-4 pb-2">
<keep-alive>
<component
:is="(tabs[selectedTabs[idx]] || tabs[0]).component"
:ref="(tabs[selectedTabs[idx]] || tabs[0]).name"
v-model="$store.getters.pipeline"
@update="$emit('save')"
/>
</keep-alive>
</div>
</v-card>
</v-col>
</v-row>
</v-container>
<v-snackbar
v-model="showNTWarning"
color="error"
timeout="-1"
top
>
{{ $store.state.settings.networkSettings.runNTServer ?
"NetworkTables server enabled! PhotonLib may not work." :
"NetworkTables not connected! Are you on a network with a robot?" }}
<template v-slot:action>
<v-btn
text
@click="hideNTWarning = true"
>
Hide
</v-btn>
</template>
</v-snackbar>
<v-dialog
v-model="dialog"
width="500"
>
<v-card
color="primary"
dark
>
<v-card-title>
Current resolution not calibrated
</v-card-title>
<v-card-text>
Because the current resolution {{ this.$store.getters.currentVideoFormat.width }} x {{ this.$store.getters.currentVideoFormat.height }} is not yet calibrated, 3D mode cannot be enabled. Please
<a
href="/cameras"
class="white--text"
@click="$emit('switch-to-cameras')"
> visit the Cameras tab</a> to calibrate this resolution. For now, SolvePNP will do nothing.
</v-card-text>
<v-divider />
<v-card-actions>
<v-spacer />
<v-btn
color="white"
text
@click="closeUncalibratedDialog"
>
OK
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import CameraAndPipelineSelect from "../components/pipeline/CameraAndPipelineSelect";
import cvImage from '../components/common/cv-image';
import InputTab from './PipelineViews/InputTab';
import ThresholdTab from './PipelineViews/ThresholdTab';
import ContoursTab from './PipelineViews/ContoursTab';
import OutputTab from './PipelineViews/OutputTab';
import TargetsTab from "./PipelineViews/TargetsTab";
import Map3DTab from './PipelineViews/Map3DTab';
import PnPTab from './PipelineViews/PnPTab';
import AprilTagTab from './PipelineViews/AprilTagTab';
import ArucoTab from './PipelineViews/ArucoTab';
export default {
name: 'Pipeline',
components: {
CameraAndPipelineSelect,
cvImage,
InputTab,
ThresholdTab,
ContoursTab,
OutputTab,
TargetsTab,
Map3DTab,
PnPTab,
AprilTagTab,
ArucoTab,
},
data() {
return {
selectedTabsData: [0, 0, 0, 0],
counterData: 0,
dialog: false,
processingModeOverride: false,
hideNTWarning: false,
}
},
computed: {
selectedTabs: {
get() {
return this.$store.getters.isDriverMode ? [0] : this.selectedTabsData;
},
set(value) {
this.selectedTabsData = value;
}
},
tabGroups: {
get() {
let tabs = {
input: {
name: "Input",
component: "InputTab",
},
threshold: {
name: "Threshold",
component: "ThresholdTab",
},
contours: {
name: "Contours",
component: "ContoursTab",
},
apriltag: {
name: "AprilTag",
component: "AprilTagTab",
},
aruco: {
name: "Aruco",
component: "ArucoTab",
},
output: {
name: "Output",
component: "OutputTab",
},
targets: {
name: "Targets",
component: "TargetsTab",
},
pnp: {
name: "PnP",
component: "PnPTab",
},
map3d: {
name: "3D",
component: "Map3DTab",
}
};
// If not in 3d, name "3D" is illegal
const allow3d = this.$store.getters.currentPipelineSettings.solvePNPEnabled;
// If in apriltag, "Threshold" and "Contours" are illegal -- otherwise "AprilTag" is
const isAprilTag = (this.$store.getters.currentPipelineSettings.pipelineType - 2) === 2;
const isAruco = (this.$store.getters.currentPipelineSettings.pipelineType - 2) === 3;
// 2D array of tab names and component names; each sub-array is a separate tab group
let ret = [];
if (this.$vuetify.breakpoint.smAndDown || this.$store.getters.isDriverMode || (this.$vuetify.breakpoint.mdAndDown && !this.$store.state.compactMode)) {
// One big tab group with all the tabs
ret[0] = Object.values(tabs);
} else if (this.$vuetify.breakpoint.mdAndDown || !this.$store.state.compactMode) {
// Two tab groups, one with "input, threshold, contours, output" and the other with "target info, 3D"
ret[0] = [tabs.input, tabs.threshold, tabs.contours, tabs.apriltag, tabs.aruco, tabs.output];
ret[1] = [tabs.targets, tabs.pnp, tabs.map3d];
} else if (this.$vuetify.breakpoint.lgAndDown) {
// Three tab groups, one with "input", one with "threshold, contours, output", and the other with "target info, 3D"
ret[0] = [tabs.input];
ret[1] = [tabs.threshold, tabs.contours, tabs.apriltag,tabs.aruco, tabs.output];
ret[2] = [tabs.targets, tabs.pnp, tabs.map3d];
} else if (this.$vuetify.breakpoint.xl) {
// Three tab groups, one with "input", one with "threshold, contours", and the other with "output, target info, 3D"
ret[0] = [tabs.input];
ret[1] = [tabs.threshold];
ret[2] = [tabs.contours, tabs.apriltag, tabs.aruco,tabs.output];
ret[3] = [tabs.targets, tabs.pnp, tabs.map3d];
}
for(let i = 0; i < ret.length; i++) {
const group = ret[i];
// All the tabs we allow
ret[i] = group.filter(it =>
!(!allow3d && it.name === "3D") //Filter out 3D tab any time 3D isn't calibrated
&& !((!allow3d || isAprilTag || isAruco) && it.name === "PnP") //Filter out the PnP config tab if 3D isn't available, or we're doing Apriltags
&& !((isAprilTag || isAruco) && (it.name === "Threshold")) //Filter out threshold tab if we're doing apriltags
&& !((isAprilTag || isAruco) && (it.name === "Contours")) //Filter out contours if we're doing Apriltag
&& !(!isAprilTag && it.name === "AprilTag") //Filter out apriltag unless we actually are doing Apriltags
&& !(!isAruco && it.name === "Aruco")
);
}
// One last filter to remove empty lists
return ret.filter(it => it !== undefined && it.length > 0);
}
},
processingMode: {
get() {
return (this.$store.getters.currentPipelineSettings.solvePNPEnabled || this.processingModeOverride) ? 1 : 0;
},
set(value) {
if (this.$store.getters.isCalibrated) {
this.$store.getters.currentPipelineSettings.solvePNPEnabled = value === 1;
this.handlePipelineUpdate("solvePNPEnabled", value === 1);
}
}
},
driverMode: {
get() {
return this.$store.getters.isDriverMode;
},
set(value) {
this.$store.getters.currentCameraSettings.currentPipelineIndex = value ? -1 : 0;
this.handleInputWithIndex('currentPipeline', value ? -1 : 0);
}
},
selectedOutputs: {
// All this logic exists to deal with the reality that the output select buttons sometimes need an array and sometimes need a number (depending on whether they're exclusive)
get() {
// We switch the selector to single-select only on sm-and-down size devices, so we have to return a Number instead of an Array in that state
let ret = [];
if (this.$store.state.colorPicking) {
ret = [0]; // We want the input stream only while color picking
} else if (this.$store.getters.isDriverMode) {
ret = [1]; // We want only the output stream in driver mode
} else {
if (this.$store.getters.currentPipelineSettings.inputShouldShow) ret = ret.concat([0]);
if (this.$store.getters.currentPipelineSettings.outputShouldShow) ret = ret.concat([1]);
if (!ret.length) ret = [0];
}
if (this.$vuetify.breakpoint.mdAndUp) {
return ret;
} else {
return ret[0] || 0;
}
},
set(value) {
let valToCommit = [0];
if (value instanceof Array) {
// Value is already an array, we don't need to do anything
valToCommit = value;
} else if (value) {
// Value is assumed to be a number, so we wrap it into an array
valToCommit = [value];
}
this.$store.commit("mutatePipeline", {"inputShouldShow": valToCommit.includes(0)});
this.$store.commit("mutatePipeline", {"outputShouldShow": valToCommit.includes(1)});
this.handlePipelineUpdate("inputShouldShow", valToCommit.includes(0));
}
},
fpsTooLow: {
get() {
// For now, we only show the FPS is too low warning when GPU acceleration is enabled, because we don't really trust the presented video modes otherwise
const currFPS = this.$store.state.pipelineResults.fps;
const targetFPS = this.$store.getters.currentVideoFormat.fps;
const driverMode = this.$store.getters.isDriverMode;
const gpuAccel = this.$store.state.settings.general.gpuAcceleration;
const isReflective = this.$store.getters.pipelineType === 2;
return (currFPS - targetFPS) < -5 && this.$store.state.pipelineResults.fps !== 0 && !driverMode && gpuAccel && isReflective;
}
},
latency: {
get() {
return this.$store.getters.currentPipelineResults.latency;
}
},
isCalibrated: {
get() {
const resolution = this.$store.getters.videoFormatList[this.$store.getters.currentPipelineSettings.cameraVideoModeIndex];
return this.$store.getters.currentCameraSettings.calibrations
.some(e => e.width === resolution.width && e.height === resolution.height)
}
},
isRobotConnected: {
get() {
// return this.$store.state.ntConnectionInfo.connected && this.$store.state.backendConnected;
return true;
}
},
showNTWarning: {
get() {
return (!this.$store.state.ntConnectionInfo.connected || this.$store.state.settings.networkSettings.runNTServer) && this.$store.state.settings.networkSettings.ntServerAddress != "" && this.$store.state.backendConnected && !this.hideNTWarning;
}
},
},
created() {
this.$store.state.connectedCallbacks.push(this.reloadStreams)
},
methods: {
reloadStreams() {
// Reload the streams as we technically close and reopen them
this.$refs.streams.forEach(it => it.reload())
},
onImageClick(event) {
// Get a reference to the threshold tab (if it is shown) and call its "onClick" method
let ref = this.$refs["Threshold"];
if (ref && ref[0])
ref[0].onClick(event)
},
on3DClick() {
if (!this.$store.getters.isCalibrated) {
this.dialog = true;
this.processingModeOverride = true;
}
},
closeUncalibratedDialog() {
this.dialog = false;
this.processingModeOverride = false;
// this.$store.getters.currentPipelineSettings.solvePNPEnabled = false;
this.handlePipelineUpdate("solvePNPEnabled", false);
}
}
}
</script>
<style scoped>
.v-btn-toggle.fill {
width: 100%;
height: 100%;
}
.v-btn-toggle.fill > .v-btn {
width: 50%;
height: 100%;
}
th {
width: 80px;
text-align: center;
}
</style>

View File

@@ -1,154 +0,0 @@
<template>
<div>
<CVselect
v-model="selectedFamily"
name="Target family"
:list="['AprilTag family 36h11', 'AprilTag family 25h9', 'AprilTag family 16h5']"
select-cols="8"
@input="handlePipelineUpdate('tagFamily', selectedFamily)"
/>
<CVslider
v-model="decimate"
class="pt-2"
slider-cols="8"
name="Decimate"
min="1"
max="8"
step="1.0"
tooltip="Increases FPS at the expense of range by reducing image resolution initially"
@input="handlePipelineData('decimate')"
/>
<CVslider
v-model="blur"
class="pt-2"
slider-cols="8"
name="Blur"
min="0"
max="5"
step=".01"
tooltip="Gaussian blur added to the image, high FPS cost for slightly decreased noise"
@input="handlePipelineData('blur')"
/>
<CVslider
v-model="threads"
class="pt-2"
slider-cols="8"
name="Threads"
min="1"
max="8"
step="1"
tooltip="Number of threads spawned by the AprilTag detector"
@input="handlePipelineData('threads')"
/>
<CVswitch
v-model="refineEdges"
class="pt-2"
slider-cols="8"
name="Refine Edges"
tooltip="Further refines the AprilTag corner position initial estimate, suggested left on"
@input="handlePipelineData('refineEdges')"
/>
<CVslider
v-model="decisionMargin"
class="pt-2 pb-4"
slider-cols="8"
name="Decision Margin Cutoff"
min="0"
max="250"
step="1"
tooltip="Tags with a 'margin' (decoding quality score) less than this wil be rejected. Increase this to reduce the number of false positive detections"
@input="handlePipelineData('decisionMargin')"
/>
<CVslider
v-model="numIterations"
class="pt-2 pb-4"
slider-cols="8"
name="Pose Estimation Iterations"
min="0"
max="500"
step="1"
tooltip="Number of iterations the pose estimation algorithm will run, 50-100 is a good starting point"
@input="handlePipelineData('numIterations')"
/>
</div>
</template>
<script>
import CVslider from '../../components/common/cv-slider'
import CVswitch from '../../components/common/cv-switch'
import CVselect from '../../components/common/cv-select'
export default {
name: "AprilTag",
components: {
CVslider,
CVswitch,
CVselect,
},
data() {
return {
familyList: ["AprilTag family 36h11", "AprilTag family 25h9", "AprilTag family 16h5"],
}
},
computed: {
selectedFamily: {
get() {
return this.$store.getters.currentPipelineSettings.tagFamily
},
set(val) {
this.$store.commit("mutatePipeline", {"tagFamily": val})
}
},
decimate: {
get() {
return this.$store.getters.currentPipelineSettings.decimate
},
set(val) {
this.$store.commit("mutatePipeline", {"decimate": val});
}
},
decisionMargin: {
get() {
return this.$store.getters.currentPipelineSettings.decisionMargin
},
set(val) {
this.$store.commit("mutatePipeline", {"decisionMargin": val});
}
},
numIterations: {
get() {
return this.$store.getters.currentPipelineSettings.numIterations
},
set(val) {
this.$store.commit("mutatePipeline", {"numIterations": val});
}
},
blur: {
get() {
return this.$store.getters.currentPipelineSettings.blur
},
set(val) {
this.$store.commit("mutatePipeline", {"blur": val});
}
},
threads: {
get() {
return this.$store.getters.currentPipelineSettings.threads
},
set(val) {
this.$store.commit("mutatePipeline", {"threads": val});
}
},
refineEdges: {
get() {
return this.$store.getters.currentPipelineSettings.refineEdges
},
set(val) {
this.$store.commit("mutatePipeline", {"refineEdges": val});
}
},
},
methods: {
}
}
</script>

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