UI bug fixes and feature refinements (#59)

* Rework settings page; touch up contour, output, and 3D tabs; font sizing

No stream placeholder; driver mode refined; cameras page

Make settings snackbar work

Lint fix

Fix settings page padding

Actually hide settings fields if unsupported

* Make toggle buttons less confusing; fix driver toggle; form validation

* Make eyedropper work and make input/select styling more consistent

* Fix color picker and tabbing bugs

* Set up camera and settings pages to talk to the backend

* Add auto reconnect

* Add lots of tooltips and improve related thematic consistency

* Only show output stream while color picking

* Unbreak robot offset

* Increase tooltip delay and refactor tooltip label into a component

* Remove toggle button switching behavior

* Fix PnP tab and add a flag to disable FOV configuration

* Move FPS indicator

* Make GPU acceleration status use one value in the store

* Only allow IPv4 static IPs and remove accidentally committed index
This commit is contained in:
Declan Freeman-Gleason
2020-07-31 13:50:50 -07:00
committed by GitHub
parent 0b98dc3c9f
commit 19b57235fe
34 changed files with 1099 additions and 566 deletions

View File

@@ -1,133 +1,180 @@
<template>
<div>
<div>
<CVselect
v-model="currentCameraIndex"
name="Camera"
:list="$store.getters.cameraList"
@input="handleInput('currentCamera',currentCameraIndex)"
/>
<CVnumberinput
v-model="cameraSettings.fov"
name="Diagonal FOV"
/>
<br>
<CVnumberinput
v-model="cameraSettings.tilt"
name="Camera pitch"
:step="0.01"
/>
<br>
<v-btn
style="margin-top:10px"
small
color="#ffd843"
@click="sendCameraSettings"
<v-row
no-gutters
class="pa-3"
>
<v-col
cols="12"
md="7"
>
Save Camera Settings
</v-btn>
</div>
<div style="margin-top: 15px">
<span>3D Calibration</span>
<v-divider
color="white"
style="margin-bottom: 10px"
/>
<v-row>
<v-col>
<CVselect
v-model="resolutionIndex"
name="Resolution"
:list="stringResolutionList"
/>
</v-col>
<v-col>
<CVnumberinput
v-model="squareSize"
name="Square Size (in)"
/>
</v-col>
</v-row>
<v-row>
<v-col>
<v-btn
small
:color="calibrationModeButton.color"
:disabled="checkResolution"
@click="sendCalibrationMode"
>
{{ calibrationModeButton.text }}
</v-btn>
</v-col>
<v-col>
<v-btn
small
:color="cancellationModeButton.color"
:disabled="checkCancellation"
@click="sendCalibrationFinish"
>
{{ cancellationModeButton.text }}
</v-btn>
</v-col>
<v-col>
<v-btn
color="whitesmoke"
small
@click="downloadBoard"
>
Download Checkerboard
</v-btn>
<a
ref="calibrationFile"
style="color: black; text-decoration: none; display: none"
:href="require('../../assets/chessboard.png')"
download="Calibration Board.png"
/>
</v-col>
</v-row>
<v-row v-if="isCalibrating">
<v-col>
<span>Snapshot Amount: {{ snapshotAmount }}</span>
</v-col>
</v-row>
<div v-if="isCalibrating">
<v-checkbox
v-model="isAdvanced"
label="Advanced Menu"
<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"
select-cols="10"
:list="$store.getters.cameraList"
@input="handleInput('currentCamera',currentCameraIndex)"
/>
<CVnumberinput
v-if="cameraSettings.isFovConfigurable"
v-model="cameraSettings.fov"
tooltip="Field of view (in degrees) of the camera measured across the diagonal of the frame"
name="Diagonal FOV"
/>
<br>
<CVnumberinput
v-model="cameraSettings.tilt"
name="Camera pitch"
tooltip="How many degrees above the horizontal the physical camera is tilted"
:step="0.01"
/>
<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>
<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="8">
<CVselect
v-model="resolutionIndex"
name="Resolution"
:list="stringResolutionList"
/>
</v-col>
<v-col
cols="4"
align-self="center"
>
<CVnumberinput
v-model="squareSize"
name="Square Size (in)"
tooltip="Length of one side of the checkerboard's square in inches"
label-cols="unset"
/>
</v-col>
</v-row>
<v-row>
<v-col>
<v-btn
small
color="secondary"
:disabled="checkResolution"
@click="sendCalibrationMode"
>
{{ calibrationModeButton.text }}
</v-btn>
</v-col>
<v-col>
<v-btn
small
color="red"
:disabled="checkCancellation"
@click="sendCalibrationFinish"
>
{{ cancellationModeButton.text }}
</v-btn>
</v-col>
<v-col>
<v-btn
color="accent"
small
outlined
@click="downloadBoard"
>
<v-icon left>
mdi-download
</v-icon>
Download Checkerboard
</v-btn>
<a
ref="calibrationFile"
style="color: black; text-decoration: none; display: none"
:href="require('../assets/chessboard.png')"
download="Calibration Board.png"
/>
</v-col>
</v-row>
<v-row v-if="isCalibrating">
<v-col>
<span>Snapshot Amount: {{ snapshotAmount }}</span>
</v-col>
</v-row>
<div v-if="isCalibrating">
<v-checkbox
v-model="isAdvanced"
label="Advanced Menu"
dark
/>
<div v-if="isAdvanced">
<CVslider
v-model="$store.getters.pipeline.exposure"
name="Exposure"
:min="0"
:max="100"
@input="e=> handleInput('exposure', e)"
/>
<CVslider
v-model="$store.getters.pipeline.brightness"
name="Brightness"
:min="0"
:max="100"
@input="e=> handleInput('brightness', e)"
/>
<CVslider
v-if="$store.getters.pipeline.gain !== -1"
v-model="$store.getters.pipeline.gain"
name="Gain"
:min="0"
:max="100"
@input="e=> handleInput('gain', e)"
/>
<CVselect
v-model="$store.getters.pipeline.videoModeIndex"
name="FPS"
:list="stringFpsList"
@input="changeFps"
/>
</div>
</div>
</div>
</v-card>
</v-col>
<v-col
class="pl-md-3 pt-3 pt-md-0"
cols="12"
md="5"
>
<CVimage
:address="$store.getters.streamAddress[1]"
:disconnected="!$store.state.backendConnected"
scale="100"
style="border-radius: 5px;"
/>
<div v-if="isAdvanced">
<CVslider
v-model="$store.getters.pipeline.exposure"
name="Exposure"
:min="0"
:max="100"
@input="e=> handleInput('exposure', e)"
/>
<CVslider
v-model="$store.getters.pipeline.brightness"
name="Brightness"
:min="0"
:max="100"
@input="e=> handleInput('brightness', e)"
/>
<CVslider
v-if="$store.getters.pipeline.gain !== -1"
v-model="$store.getters.pipeline.gain"
name="Gain"
:min="0"
:max="100"
@input="e=> handleInput('gain', e)"
/>
<CVselect
v-model="$store.getters.pipeline.videoModeIndex"
name="FPS"
:list="stringFpsList"
@input="changeFps"
/>
</div>
</div>
</div>
</v-col>
</v-row>
<v-snackbar
v-model="snack"
top
@@ -139,16 +186,18 @@
</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 CVselect from '../components/common/cv-select';
import CVnumberinput from '../components/common/cv-number-input';
import CVslider from '../components/common/cv-slider';
import CVimage from "../components/common/cv-image";
export default {
name: 'CameraSettings',
name: 'Cameras',
components: {
CVselect,
CVnumberinput,
CVslider
CVslider,
CVimage
},
data() {
return {
@@ -243,7 +292,7 @@
},
cameraSettings: {
get() {
return this.$store.getters.cameraSettings;
return this.$store.getters.currentCameraSettings;
},
set(value) {
this.$store.commit('cameraSettings', value);
@@ -252,7 +301,7 @@
},
methods: {
downloadBoard() {
this.axios.get("http://" + this.$address + require('../../assets/chessboard.png'), {responseType: 'blob'}).then((response) => {
this.axios.get("http://" + this.$address + require('../assets/chessboard.png'), {responseType: 'blob'}).then((response) => {
require('downloadjs')(response.data, "Calibration Board", "image/png")
})
},

View File

@@ -11,42 +11,58 @@
>
<v-col
cols="12"
:class="['pb-3 ', $store.getters.isDriverMode ? '' : 'pr-lg-3']"
:lg="$store.getters.isDriverMode ? 12 : 8"
: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="height: 10%;"
style="height: 15%; min-height: 50px;"
>
Cameras
<div>
Cameras <span
class="pl-2 caption grey--text text--lighten-2"
style="line-height: 220%; display: inline-block; vertical-align: bottom;"
>{{ parseFloat(fps).toFixed(2) }} FPS</span>
</div>
<v-switch
v-model="driverMode"
label="Driver Mode"
style="margin-left: auto;"
color="accent"
/>
</v-card-title>
<v-row
align="center"
style="height: 90%;"
>
<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)"
class="pb-0 pt-0"
style="height: 100%;"
>
<div style="position: relative; width: 100%; height: 100%;">
<cvImage
:id="idx === 0 ? 'normal-stream' : ''"
:address="$store.getters.streamAddress[idx]"
:disconnected="!$store.state.backendConnected"
scale="100"
max-height="300px"
max-height-md="320px"
max-height-xl="450px"
:max-height="$store.getters.isDriverMode ? '40vh' : '300px'"
:max-height-md="$store.getters.isDriverMode ? '50vh' : '320px'"
:max-height-xl="$store.getters.isDriverMode ? '60vh' : '450px'"
:alt="'Stream' + idx"
:color-picking="$store.state.colorPicking && idx == 0"
@click="onImageClick"
/>
<span style="position: absolute; top: 2%; left: 2%; font-size: 28px; -webkit-text-stroke: 1px black;">{{ parseFloat(fps).toFixed(2) }}</span>
<!-- <span class="fps-indicator">{{ parseFloat(fps).toFixed(2) }}</span>-->
</div>
</v-col>
</v-row>
@@ -55,7 +71,7 @@
<v-col
cols="12"
class="pb-3"
:lg="$store.getters.isDriverMode ? 12 : 4"
lg="4"
align-self="stretch"
>
<v-card
@@ -64,7 +80,7 @@
<camera-and-pipeline-select />
</v-card>
<v-card
v-if="!$store.getters.isDriverMode"
:disabled="$store.getters.isDriverMode || $store.state.colorPicking"
class="mt-3"
color="primary"
>
@@ -82,14 +98,18 @@
dark
class="fill"
>
<v-btn color="secondary">
<v-icon>mdi-cube-outline</v-icon>
<span>3D</span>
</v-btn>
<v-btn color="secondary">
<v-btn
color="secondary"
>
<v-icon>mdi-crop-square</v-icon>
<span>2D</span>
</v-btn>
<v-btn
color="secondary"
>
<v-icon>mdi-cube-outline</v-icon>
<span>3D</span>
</v-btn>
</v-btn-toggle>
</v-col>
<v-col lg="12">
@@ -146,7 +166,7 @@
slider-color="accent"
>
<v-tab
v-for="(tab, i) in tabs.filter(it => it.name !== '3D' || is3D)"
v-for="(tab, i) in tabs.filter(it => it.name !== '3D' || $store.getters.currentPipelineSettings.is3D)"
:key="i"
>
{{ tab.name }}
@@ -154,12 +174,10 @@
</v-tabs>
<div class="pl-4 pr-4 pt-2">
<keep-alive>
<!-- vision component -->
<component
:is="(tabs[selectedTabs[idx]] || tabs[0]).component"
ref="component"
:ref="(tabs[selectedTabs[idx]] || tabs[0]).name"
v-model="$store.getters.pipeline"
:is3d="is3D"
@update="$emit('save')"
/>
</keep-alive>
@@ -211,12 +229,20 @@
},
data() {
return {
selectedTabs: [0, 0, 0, 0],
selectedTabsData: [0, 0, 0, 0],
snackbar: false,
is3D: false,
counterData: 0,
}
},
computed: {
selectedTabs: {
get() {
return this.$store.getters.isDriverMode ? [0] : this.selectedTabsData;
},
set(value) {
this.selectedTabsData = value;
}
},
tabGroups: {
get() {
let tabs = {
@@ -248,10 +274,10 @@
// 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.state.compactMode || this.$store.getters.isDriverMode) {
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) {
} 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.output];
ret[1] = [tabs.targets, tabs.pnp];
@@ -273,10 +299,20 @@
},
processingMode: {
get() {
return this.is3D ? 0 : 1;
return this.$store.getters.currentPipelineSettings.is3D ? 1 : 0;
},
set(value) {
this.is3D = value === 0;
this.$store.getters.currentPipelineSettings.is3D = value === 1;
this.handlePipelineUpdate("is3D", 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: {
@@ -284,7 +320,9 @@
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.getters.isDriverMode) {
if (this.$store.state.colorPicking) {
ret = [0]; // We want the input stream only while color picking
} else if (!this.$store.getters.isDriverMode) {
ret = this.$store.state.selectedOutputs || [0];
} else {
ret = [1]; // We want the output stream in driver mode
@@ -324,9 +362,12 @@
},
methods: {
onImageClick(event) {
if (this.selectedTab === 1) {
this.$refs.component.onClick(event);
}
// Only run on the input stream
if (event.target.alt !== "Stream0") return;
// 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)
},
}
}
@@ -343,12 +384,12 @@
height: 100%;
}
.colsClass {
padding: 0 !important;
}
.videoClass {
text-align: center;
.fps-indicator {
position: absolute;
top: 2%;
left: 2%;
font-size: 1.75rem;
text-shadow: 1px 1px 5px rgba(1, 1, 1, 0.65);
}
th {

View File

@@ -12,6 +12,7 @@
<CVrangeSlider
v-model="contourRatio"
name="Ratio (W/H)"
tooltip="Min and max ratio between the width and height of a contour's bounding rectangle"
min="0"
max="100"
step="0.1"
@@ -21,6 +22,7 @@
<CVrangeSlider
v-model="contourFullness"
name="Fullness"
tooltip="Min and max ratio between a contour's area and its bounding rectangle"
min="0"
max="100"
@input="handlePipelineData('contourFullness')"
@@ -29,6 +31,7 @@
<CVslider
v-model="contourSpecklePercentage"
name="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="largeBox"
@@ -37,7 +40,8 @@
/>
<CVselect
v-model="contourGroupingMode"
name="Target Group"
name="Target Grouping"
tooltip="Whether or not every two targets are paired with each other (good for e.g. 2019 targets)"
:select-cols="largeBox"
:list="['Single','Dual']"
@input="handlePipelineData('targetGroup')"
@@ -46,12 +50,22 @@
<CVselect
v-model="contourIntersection"
name="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="largeBox"
:list="['None','Up','Down','Left','Right']"
:disabled="contourGroupingMode === 0"
@input="handlePipelineData('contourIntersection')"
@rollback="e=> rollback('contourIntersection',e)"
/>
<CVselect
v-model="contourSortMode"
name="Target Sort"
tooltip="Chooses the sorting mode used to determine the 'best' targets to provide to user code"
:select-cols="largeBox"
:list="['Largest','Smallest','Highest','Lowest','Rightmost','Leftmost','Centermost']"
@input="handlePipelineData('contourSortMode')"
@rollback="e => rollback('contourSortMode', e)"
/>
</div>
</template>
@@ -122,6 +136,14 @@
this.$store.commit("mutatePipeline", {"contourGroupingMode": val});
}
},
contourSortMode: {
get() {
return this.$store.getters.currentPipelineSettings.contourSortMode
},
set(val) {
this.$store.commit("mutatePipeline", {"contourSortMode": val});
}
},
contourIntersection: {
get() {
return this.$store.getters.currentPipelineSettings.contourIntersection

View File

@@ -5,6 +5,7 @@
name="Exposure"
min="0"
max="100"
tooltip="Directly controls how much light is allowed to fall onto the sensor, which affects brightness"
:slider-cols="largeBox"
@input="handlePipelineData('cameraExposure')"
@rollback="e => rollback('cameraExposure', e)"
@@ -14,6 +15,7 @@
name="Brightness"
min="0"
max="100"
tooltip="Controls camera postprocessing that brightens or darkens the image uniformly"
:slider-cols="largeBox"
@input="handlePipelineData('cameraBrightness')"
@rollback="e => rollback('cameraBrightness', e)"
@@ -24,6 +26,7 @@
name="Gain"
min="0"
max="100"
tooltip="Controls automatic white balance gain, which affects how the camera captures colors in different conditions"
:slider-cols="largeBox"
@input="handlePipelineData('cameraGain')"
@rollback="e => rollback('cameraGain', e)"
@@ -31,6 +34,7 @@
<CVselect
v-model="inputImageRotationMode"
name="Orientation"
tooltip="Rotates the camera stream"
:list="['Normal','90° CW','180°','90° CCW']"
:select-cols="largeBox"
@input="handlePipelineData('inputImageRotationMode')"
@@ -39,6 +43,7 @@
<CVselect
v-model="cameraVideoModeIndex"
name="Resolution"
tooltip="Resolution and FPS the camera should directly capture at"
:list="resolutionList"
:select-cols="largeBox"
@input="handlePipelineData('cameraVideoModeIndex')"
@@ -47,6 +52,7 @@
<CVselect
v-model="streamingFrameDivisor"
name="Stream Resolution"
tooltip="Resolution to which camera frames are downscaled for streaming to the dashboard"
:list="streamResolutionList"
:select-cols="largeBox"
@input="handlePipelineData('streamingFrameDivisor')"

View File

@@ -1,18 +1,12 @@
<template>
<div>
<span>Contour Sorting</span>
<span>Target Manipulation</span>
<v-divider class="mt-2" />
<CVselect
v-model="contourSortMode"
name="Sort Mode"
:list="['Largest','Smallest','Highest','Lowest','Rightmost','Leftmost','Centermost']"
@input="handlePipelineData('contourSortMode')"
@rollback="e => rollback('contourSortMode', e)"
/>
<CVselect
v-model="contourTargetOffsetPointEdge"
name="Target Offset Point"
tooltip="Changes where the 'center' of the target is (used for calculating e.g. pitch and yaw)"
:list="['Center','Top','Bottom','Left','Right']"
@input="handlePipelineData('contourTargetOffsetPointEdge')"
@rollback="e=> rollback('contourTargetOffsetPointEdge', e)"
@@ -21,6 +15,7 @@
<CVselect
v-model="contourTargetOrientation"
name="Target Orientation"
tooltip="Used to determine how to calculate target landmarks (e.g. the top, left, or bottom of the target)"
:list="['Portrait', 'Landscape']"
@input="handlePipelineData('contourTargetOrientation')"
@rollback="e=> rollback('contourTargetOrientation', e)"
@@ -29,7 +24,9 @@
<CVswitch
v-model="outputShowMultipleTargets"
name="Show Multiple Targets"
tooltip="If enabled, up to five targets will be displayed and sent to user code"
class="mb-4"
text-cols="3"
@input="handlePipelineData('outputShowMultipleTargets')"
@rollback="e=> rollback('outputShowMultipleTargets', e)"
@@ -39,6 +36,7 @@
<CVselect
v-model="offsetRobotOffsetMode"
name="Robot Offset Mode"
tooltip="Used to add an arbitrary offset to the location of the targeting crosshair"
:list="['None','Single Point','Dual Point']"
@input="handlePipelineData('offsetRobotOffsetMode')"
@rollback="e=> rollback('offsetRobotOffsetMode',e)"
@@ -93,16 +91,6 @@
}
},
computed: {
contourSortMode: {
get() {
return this.$store.getters.currentPipelineSettings.contourSortMode
},
set(val) {
this.$store.commit("mutatePipeline", {"contourSortMode": val});
}
},
contourTargetOffsetPointEdge: {
get() {
return this.$store.getters.currentPipelineSettings.contourTargetOffsetPointEdge
@@ -138,13 +126,13 @@
selectedComponent: {
get() {
switch (this.value.calibrationMode) {
switch (this.offsetRobotOffsetMode) {
case 0:
return "";
return null;
case 1:
return "Single Point";
return SingleCalibration;
case 2:
return "Dual Point"
return DualCalibration;
}
return ""
}

View File

@@ -1,6 +1,6 @@
<template>
<div>
<!-- Special hidden upload input that gets 'clicked' when the user selects the right dropdown item' -->
<!-- Special hidden upload input that gets 'clicked' when the user selects the right dropdown item -->
<input
ref="file"
type="file"
@@ -21,7 +21,6 @@
item-value="data"
@change="onModelSelect"
/>
<v-divider />
<CVslider
v-model="value.accuracy"
class="pt-2"
@@ -33,7 +32,6 @@
@input="handleData('accuracy')"
@rollback="e => rollback('accuracy', e)"
/>
<v-divider class="pb-2" />
<mini-map
class="miniMapClass"
:targets="targets"
@@ -77,7 +75,7 @@
computed: {
targets: {
get() {
return "FIXME"; // TODO fix
return this.$store.getters.currentPipelineResults.targets;
}
},
horizontalFOV: {
@@ -100,7 +98,7 @@
let tmp = [];
for (let t in FRCtargetsConfig) {
if (FRCtargetsConfig.hasOwnProperty(t)) {
tmp.push({name: t, data: FRCtargetsConfig[t]})
tmp.push({name: t, data: FRCtargetsConfig[t]});
}
}

View File

@@ -13,12 +13,12 @@
dark
>
<template v-slot:default>
<thead style="font-size: 20px;">
<thead style="font-size: 1.25rem;">
<tr>
<th class="text-center">
Target
</th>
<template v-if="!is3D">
<template v-if="!$store.getters.currentPipelineSettings.is3D">
<th class="text-center">
Pitch
</th>
@@ -32,7 +32,7 @@
<th class="text-center">
Area
</th>
<template v-if="is3D">
<template v-if="$store.getters.currentPipelineSettings.is3D">
<th class="text-center">
X
</th>
@@ -51,17 +51,17 @@
:key="index"
>
<td>{{ index }}</td>
<template v-if="!is3D">
<template v-if="!$store.getters.currentPipelineSettings.is3D">
<td>{{ parseFloat(value.pitch).toFixed(2) }}</td>
<td>{{ parseFloat(value.yaw).toFixed(2) }}</td>
<td>{{ parseFloat(value.skew).toFixed(2) }}</td>
</template>
<td>{{ parseFloat(value.area).toFixed(2) }}</td>
<template v-if="is3D">
<template v-if="$store.getters.currentPipelineSettings.is3D">
<!-- TODO: Make sure that units are correct -->
<td>{{ parseFloat(value.pose.x).toFixed(2) }}&nbsp;m</td>
<td>{{ parseFloat(value.pose.y).toFixed(2) }}&nbsp;m</td>
<td>{{ parseFloat(value.pose.rot).toFixed(2) }}&deg;</td>
<td>{{ parseFloat(value.pose.rotation).toFixed(2) }}&deg;</td>
</template>
</tr>
</tbody>
@@ -74,9 +74,6 @@
<script>
export default {
name: "TargetsTab",
props: {
is3D: Boolean,
}
}
</script>
@@ -97,6 +94,10 @@
font-size: 1rem !important;
}
.v-data-table td {
font-family: monospace !important;
}
/** This is unfortunately the only way to override table background color **/
.theme--dark.v-data-table tbody tr:hover:not(.v-data-table__expanded__content):not(.v-data-table__empty-wrapper) {
background: #005281;

View File

@@ -3,6 +3,7 @@
<CVrangeSlider
v-model="hsvHue"
name="Hue"
tooltip="Describes color"
:min="0"
:max="180"
@input="handlePipelineData('hsvHue')"
@@ -11,6 +12,7 @@
<CVrangeSlider
v-model="hsvSaturation"
name="Saturation"
tooltip="Describes colorfulness; the smaller this value the 'whiter' the color becomes"
:min="0"
:max="255"
@input="handlePipelineData('hsvSaturation')"
@@ -19,53 +21,81 @@
<CVrangeSlider
v-model="hsvValue"
name="Value"
tooltip="Describes lightness; the smaller this value the 'blacker' the color becomes"
:min="0"
:max="255"
@input="handlePipelineData('hsvValue')"
@rollback="e => rollback('value',e)"
/>
<div class="pt-3 white--text">
Color Picker
</div>
<v-divider
class="mt-3"
/>
<v-row justify="center">
<v-btn
color="accent"
class="ma-5 black--text"
small
@click="setFunction(1)"
>
<v-icon>colorize</v-icon>
Eye drop
</v-btn>
<v-btn
color="accent"
class="ma-5 black--text"
small
@click="setFunction(2)"
>
<v-icon>add</v-icon>
Expand Selection
</v-btn>
<v-btn
color="accent"
class="ma-5 black--text"
small
@click="setFunction(3)"
>
<v-icon>remove</v-icon>
Shrink Selection
</v-btn>
<v-row
justify="center"
class="mt-3 mb-3"
>
<template v-if="!$store.state.colorPicking">
<v-btn
color="accent"
class="ma-2 black--text"
small
@click="setFunction(3)"
>
<v-icon left>
mdi-minus
</v-icon>
Shrink Range
</v-btn>
<v-btn
color="accent"
class="ma-2 black--text"
small
@click="setFunction(1)"
>
<v-icon left>
mdi-plus-minus
</v-icon>
Set To Average
</v-btn>
<v-btn
color="accent"
class="ma-2 black--text"
small
@click="setFunction(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="setFunction(0)"
>
Cancel
</v-btn>
</template>
</v-row>
<v-divider />
<v-divider class="mb-3" />
<CVswitch
v-model="erode"
name="Erode"
tooltip="Removes pixels around the edges of white areas in the thresholded image"
@input="handlePipelineData('erode')"
@rollback="e => rollback('erode',e)"
/>
<CVswitch
v-model="dilate"
name="Dilate"
tooltip="Adds pixels around the edges of white areas in the thresholded image"
@input="handlePipelineData('dilate')"
@rollback="e => rollback('dilate',e)"
/>
@@ -143,14 +173,27 @@
methods: {
onClick(event) {
if (this.currentFunction !== undefined) {
this.colorPicker.initColorPicker();
let s = this.$store.getters.currentPipelineSettings;
let hsvArray = this.colorPicker.colorPickerClick(event, this.currentFunction,
[[this.value.hue[0], this.value.saturation[0], this.value.value[0]], [this.value.hue[1], this.value.saturation[1], this.value.value[1]]]);
[
[s.hsvHue[0], s.hsvSaturation[0], s.hsvValue[0]],
[s.hsvHue[1], s.hsvSaturation[1], s.hsvValue[1]]
].map(hsv => hsv.map(it => it || 0)));
// That `map` calls are to make sure that we don't let any undefined/null values slip in
this.currentFunction = undefined;
this.$store.state.colorPicking = false;
s.hsvHue = [hsvArray[0][0], hsvArray[1][0]];
s.hsvSaturation = [hsvArray[0][1], hsvArray[1][1]];
s.hsvValue = [hsvArray[0][2], hsvArray[1][2]];
let msg = this.$msgPack.encode({
"changePipelineSetting": {
'hsvHue': [hsvArray[0][0], hsvArray[1][0]],
'hsvSaturation': [hsvArray[0][1], hsvArray[1][1]],
'hsvValue': [hsvArray[0][2], hsvArray[1][2]],
'hsvHue': s.hsvHue,
'hsvSaturation': s.hsvSaturation,
'hsvValue': s.hsvValue,
'outputShowThresholded': this.showThresholdState,
'cameraIndex': this.$store.state.currentCameraIndex
}
@@ -160,15 +203,11 @@
}
},
setFunction(index) {
this.showThresholdState = this.value.outputShowThresholded;
if (this.showThresholdState === true) {
this.value.outputShowThresholded = false;
this.handlePipelineData('outputShowThresholded')
}
switch (index) {
case 0:
this.currentFunction = undefined;
break;
this.$store.state.colorPicking = false;
return;
case 1:
this.currentFunction = this.colorPicker.eyeDrop;
break;
@@ -179,6 +218,7 @@
this.currentFunction = this.colorPicker.shrink;
break;
}
this.$store.state.colorPicking = true;
}
}
}

View File

@@ -1,64 +1,73 @@
<template>
<div>
<v-row>
<v-row
class="pa-3"
no-gutters
>
<v-col
class="colsClass"
cols="6"
cols="12"
style="max-width: 1400px"
>
<v-tabs
v-model="selectedTab"
background-color="#232c37"
dark
fixed-tabs
height="50"
slider-color="#ffd843"
<v-form
ref="form"
v-model="valid"
>
<v-tab to="">
General
</v-tab>
<v-tab to="">
Cameras
</v-tab>
</v-tabs>
<div style="padding-left:30px">
<component
:is="selectedComponent"
@update="$emit('save')"
/>
</div>
</v-col>
<v-col
v-show="selectedTab === 1"
class="colsClass"
>
<div class="videoClass">
<cvImage
:address="$store.getters.streamAddress"
:scale="75"
/>
</div>
<v-card
v-for="item in tabList"
:key="item.name"
dark
class="mb-3 pr-6 pb-3"
style="background-color: #006492;"
>
<v-card-title>{{ item.name }}</v-card-title>
<component
:is="item"
class="ml-5"
/>
</v-card>
<v-btn
color="accent"
style="color: black; width: 100%;"
:disabled="!valid"
@click="sendGeneralSettings()"
>
Save
</v-btn>
</v-form>
</v-col>
</v-row>
<v-snackbar
v-model="snack"
top
:color="snackbar.color"
>
<span>{{ snackbar.text }}</span>
</v-snackbar>
</div>
</template>
<script>
import General from './SettingsViews/General'
import Cameras from './SettingsViews/Cameras'
import Networking from './SettingsViews/Networking'
import Lighting from "./SettingsViews/Lighting";
import cvImage from '../components/common/cv-image'
import General from "./SettingsViews/General";
export default {
name: 'SettingsTab',
components: {
cvImage,
General,
Cameras,
// General,
},
data() {
return {
selectedTab: 0,
tabList: [General, Cameras]
valid: true, // Are all settings valid
snack: false,
snackbar: {
color: "accent",
text: ""
},
}
},
computed: {
@@ -67,6 +76,39 @@
return this.tabList[this.selectedTab];
}
},
settings: {
get() {
return this.$store.state.settings;
}
},
tabList: {
get() {
return [General, Networking].concat(this.$store.state.settings.lighting.supported ? Lighting : []);
}
}
},
methods: {
sendGeneralSettings() {
const self = this;
this.axios.post("http://" + this.$address + "/api/settings/general", this.settings).then(
function (response) {
if (response.status === 200) {
self.snackbar = {
color: "success",
text: "Settings updated successfully"
};
self.snack = true;
}
},
function (error) {
self.snackbar = {
color: "error",
text: (error.response || {data: "Couldn't save settings"}).data
};
self.snack = true;
}
)
},
}
}
</script>
@@ -81,8 +123,4 @@
height: auto !important;
vertical-align: middle;
}
.colsClass {
padding: 0 !important;
}
</style>

View File

@@ -1,86 +1,55 @@
<template>
<div>
<div style="margin-top: 15px">
<span>General Settings:</span>
<v-divider color="white" />
</div>
<CVnumberinput
v-model="settings.teamNumber"
name="Team Number"
/>
<CVradio
v-model="settings.connectionType"
:list="['DHCP','Static']"
/>
<v-divider color="white" />
<CVinput
v-model="settings.ip"
name="IP"
:disabled="isDisabled"
/>
<CVinput
v-model="settings.netmask"
name="NetMask"
:disabled="isDisabled"
/>
<CVinput
v-model="settings.gateway"
name="Gateway"
:disabled="isDisabled"
/>
<v-divider color="white" />
<CVinput
v-model="settings.hostname"
name="Hostname"
/>
<v-btn
style="margin-top:10px"
small
color="#ffd843"
@click="sendGeneralSettings"
>
Save General Settings
</v-btn>
<div style="margin-top: 20px">
<span>Install or Update:</span>
<v-divider color="white" />
</div>
<div v-if="!isLoading">
<v-row
dense
align="center"
<span>Version: {{ settings.version }}</span>
&mdash;
<span>Hardware model: {{ settings.hardwareModel }}</span>
&mdash;
<span>Platform: {{ settings.hardwarePlatform }}</span>
&mdash;
<span>GPU Acceleration: {{ settings.gpuAcceleration ? "Enabled" : "Unsupported" }}{{ settings.gpuAcceleration ? " (" + settings.gpuAcceleration + " mode)" : "" }}</span>
<v-row>
<v-col
cols="12"
sm="6"
lg="4"
>
<v-col :cols="3">
<span>Choose a newer version: </span>
</v-col>
<v-col :cols="6">
<v-file-input
v-model="file"
accept=".jar"
dark
/>
</v-col>
</v-row>
<v-btn
small
@click="installOrUpdate"
<v-btn
color="secondary"
@click="$refs.exportSettings.click()"
>
<v-icon left>
mdi-download
</v-icon> Export Settings
</v-btn>
</v-col>
<v-col
cols="12"
sm="6"
lg="4"
>
{{ fileUploadText }}
</v-btn>
</div>
<div
v-else
style="text-align: center; margin-top: 20px"
>
<v-progress-circular
color="white"
:indeterminate="true"
size="32"
width="4"
/>
<br>
<span>Please wait this may take a while</span>
</div>
<v-btn
color="secondary"
@click="$refs.importSettings.click()"
>
<v-icon left>
mdi-upload
</v-icon> Import Settings
</v-btn>
</v-col>
<v-col
cols="12"
lg="4"
>
<v-btn
color="red"
@click="restartDevice"
>
<v-icon left>
mdi-restart
</v-icon> Restart Device
</v-btn>
</v-col>
</v-row>
<v-snackbar
v-model="snack"
top
@@ -88,101 +57,70 @@
>
<span>{{ snackbar.text }}</span>
</v-snackbar>
<!-- Special hidden upload input that gets 'clicked' when the user imports settings -->
<input
ref="importSettings"
type="file"
accept=".zip"
style="display: none;"
@change="readImportedSettings"
>
<!-- Special hidden link that gets 'clicked' when the user exports settings -->
<a
ref="exportSettings"
style="color: black; text-decoration: none; display: none"
href="/api/settings/export"
download="photonvision-settings.zip"
/>
</div>
</template>
<script>
import CVnumberinput from '../../components/common/cv-number-input'
import CVradio from '../../components/common/cv-radio'
import CVinput from '../../components/common/cv-input'
export default {
name: 'General',
components: {
CVnumberinput,
CVradio,
CVinput
},
data() {
return {
file: undefined,
snackbar: {
color: "success",
text: ""
},
snack: false,
isLoading: false
}
return {
snack: false,
snackbar: {
color: "success",
text: ""
},
}
},
computed: {
fileUploadText() {
if (this.file !== undefined) {
return "Update and run at startup"
} else {
return "Run current version at startup"
}
},
isDisabled() {
return this.settings.connectionType === 0;
},
settings: {
get() {
return this.$store.state.settings;
}
}
settings() {
return this.$store.state.settings.general;
}
},
methods: {
sendGeneralSettings() {
const self = this;
this.axios.post("http://" + this.$address + "/api/settings/general", this.settings).then(
function (response) {
if (response.status === 200) {
self.snackbar = {
color: "success",
text: "Save successful, Please restart for changes to take action"
};
self.snack = true;
}
},
function (error) {
self.snackbar = {
color: "error",
text: error.response.data
};
self.snack = true;
}
)
},
installOrUpdate() {
let formData = new FormData();
formData.append('file', this.file);
if (this.file !== undefined) {
this.isLoading = true;
}
this.axios.post("http://" + this.$address + "/api/install", formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
}).then(() => {
this.snackbar = {
color: "success",
text: "Installation successful"
};
this.isLoading = false;
this.snack = true;
}).catch(error => {
this.snackbar = {
color: "error",
text: error.response.data
};
this.isLoading = false;
this.snack = true;
})
}
readImportedSettings(event) {
let formData = new FormData();
formData.append("zipData", event.target.files[0]);
this.axios.post("http://" + this.$address + "/api/settings/import", formData,
{headers: {"Content-Type": "multipart/form-data"}}).then(() => {
this.snackbar = {
color: "success",
text: "Settings imported successfully",
};
}).catch(() => {
this.snackbar = {
color: "error",
text: "Couldn't import settings",
}
});
this.snack = true;
},
restartDevice() {
this.axios.post("http://" + this.$address + "/api/restart");
}
}
}
</script>
<style lang="" scoped>
<style lang="css" scoped>
.v-btn {
width: 100%;
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<div>
<CVslider
v-model="settings.brightness"
class="pt-2"
slider-cols="12"
name="Brightness"
min="0"
max="100"
@input="handleData('accuracy')"
@rollback="e => rollback('accuracy', e)"
/>
</div>
</template>
<script>
import CVslider from "../../components/common/cv-slider";
export default {
name: 'LEDs',
components: {
CVslider,
},
computed: {
isDHCP() {
return this.settings.connectionType === 0;
},
settings() {
return this.$store.state.settings.lighting;
}
},
}
</script>
<style lang="" scoped>
</style>

View File

@@ -0,0 +1,107 @@
<template>
<div>
<CVnumberinput
v-model="settings.teamNumber"
name="Team Number"
:rules="[v => (v > 0) || 'Team number must be greater than zero', v => (v < 10000) || 'Team number must have fewer than five digits']"
/>
<template v-if="$store.state.settings.networking.supported">
<CVradio
v-model="settings.connectionType"
:list="['DHCP','Static']"
/>
<template v-if="!isDHCP">
<CVinput
v-model="settings.ip"
:input-cols="inputCols"
:rules="[v => isIPv4(v) || 'Invalid IPv4 address']"
name="IP"
/>
<CVinput
v-model="settings.netmask"
:input-cols="inputCols"
:rules="[v => isSubnetMask(v) || 'Invalid subnet mask']"
name="Subnet Mask"
/>
</template>
</template>
<CVinput
v-model="settings.hostname"
:input-cols="inputCols"
:rules="[v => isHostname(v) || 'Invalid hostname']"
name="Hostname"
/>
</div>
</template>
<script>
import CVnumberinput from '../../components/common/cv-number-input'
import CVradio from '../../components/common/cv-radio'
import CVinput from '../../components/common/cv-input'
// 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])$/;
// https://stackoverflow.com/a/18494710
const hostnameRegex = /^([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)+(\.([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*))*$/;
export default {
name: 'Networking',
components: {
CVnumberinput,
CVradio,
CVinput
},
data() {
return {
file: undefined,
snackbar: {
color: "success",
text: ""
},
snack: false,
isLoading: false
}
},
computed: {
inputCols() {
return this.$vuetify.breakpoint.smAndUp ? 10 : 7;
},
isDHCP() {
return this.settings.connectionType === 0;
},
settings() {
return this.$store.state.settings.networking;
}
},
methods: {
isIPv4(v) {
return ipv4Regex.test(v);
},
isHostname(v) {
return hostnameRegex.test(v);
},
// https://www.freesoft.org/CIE/Course/Subnet/6.htm
// https://stackoverflow.com/a/13957228
isSubnetMask(v) {
// Has to be valid IPv4 so we'll start here
if (!this.isIPv4(v)) return false;
let octets = v.split(".").map(it => Number(it));
let restAreOnes = false;
for (let i = 3; i >= 0; i--) {
for (let j = 0; j < 8; j++) {
let bitValue = (octets[i] >>> j & 1) == 1;
if (restAreOnes && !bitValue)
return false;
restAreOnes = bitValue;
}
}
return true;
}
},
}
</script>
<style lang="" scoped>
</style>