[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

@@ -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>