mirror of
https://github.com/PhotonVision/photonvision
synced 2026-06-26 01:51:40 +00:00
Save calibration data and show preliminary GUI (#1078)
* Serialize all calibration data * Run lint * typing nit * fix code * move these tables around some * Add cool formatting * add request to get snapshots by resolution and camera * re-enable all resolutions * add wip so i can change computers (SQUASH ME AND KILL ME AHHHH) * Get everything working but viewing snapshots * Update RequestHandler.java * Update CameraCalibrationInfoCard.vue * Update CameraCalibrationInfoCard.vue * add observation viewer * round * fix illiegal import * Swap to PNG and serialize insolution * move import/export buttons TO THE TOP * Update WebsocketDataTypes.ts * Add snapshotname to observation * Refactor to serialize snapshot image itself * Run lint * Use new base64 image data in info card * Update SettingTypes.ts * Create calibration json -> mrcal converter script * Update calibrationUtils.py * Fix calibrate NPEs in teest * Run lint * Always run cornersubpix * Update CameraCalibrationInfoCard.vue Update CameraCalibrationInfoCard.vue * Update OpenCVHelp.java * Update OpenCVHelp.java * Replace test mode camera JSONs * Run wpiformat * Revert intrinsics but keep other data * Remove misc comments * Rename JsonMat->JsonImageMat and add calobject_warp * Update Server.java * Rename cameraExtrinsics to distCoeffs * fix typing issues * use util methods * Formatting fixes * fix styling * move to devTools * remove unneeded or unused imports * Remove fixed-right css If its really that big of a deal, we can add it back later, kind of a drag to fix rn. * Create util method * Remove extra legacy calibration things --------- Co-authored-by: Sriman Achanta <68172138+srimanachanta@users.noreply.github.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
|
||||
import { CalibrationBoardTypes, type Resolution, type VideoFormat } from "@/types/SettingTypes";
|
||||
import { CalibrationBoardTypes, type VideoFormat } from "@/types/SettingTypes";
|
||||
import JsPDF from "jspdf";
|
||||
import { font as PromptRegular } from "@/assets/fonts/PromptRegular";
|
||||
import MonoLogo from "@/assets/images/logoMono.png";
|
||||
@@ -12,37 +12,40 @@ import PvSelect from "@/components/common/pv-select.vue";
|
||||
import PvNumberInput from "@/components/common/pv-number-input.vue";
|
||||
import { WebsocketPipelineType } from "@/types/WebsocketDataTypes";
|
||||
import { getResolutionString, resolutionsAreEqual } from "@/lib/PhotonUtils";
|
||||
import CameraCalibrationInfoCard from "@/components/cameras/CameraCalibrationInfoCard.vue";
|
||||
|
||||
const settingsValid = ref(true);
|
||||
|
||||
const getCalibrationCoeffs = (resolution: Resolution) => {
|
||||
return useCameraSettingsStore().currentCameraSettings.completeCalibrations.find((cal) =>
|
||||
resolutionsAreEqual(cal.resolution, resolution)
|
||||
);
|
||||
};
|
||||
const getUniqueVideoResolutions = (): VideoFormat[] => {
|
||||
const getUniqueVideoFormatsByResolution = (): VideoFormat[] => {
|
||||
const uniqueResolutions: VideoFormat[] = [];
|
||||
useCameraSettingsStore().currentCameraSettings.validVideoFormats.forEach((format, index) => {
|
||||
if (!uniqueResolutions.some((v) => resolutionsAreEqual(v.resolution, format.resolution))) {
|
||||
format.index = index;
|
||||
|
||||
const calib = getCalibrationCoeffs(format.resolution);
|
||||
const calib = useCameraSettingsStore().getCalibrationCoeffs(format.resolution);
|
||||
if (calib !== undefined) {
|
||||
format.standardDeviation = calib.standardDeviation;
|
||||
format.mean =
|
||||
calib.perViewErrors === null
|
||||
? Number.NaN
|
||||
: 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);
|
||||
// Is this the right formula for RMS error? who knows! not me!
|
||||
const perViewSumSquareReprojectionError = calib.observations.flatMap((it) =>
|
||||
it.reprojectionErrors.flatMap((it2) => [it2.x, it2.y])
|
||||
);
|
||||
// For each error, square it, sum the squares, and divide by total points N
|
||||
format.mean = Math.sqrt(
|
||||
perViewSumSquareReprojectionError.map((it) => Math.pow(it, 2)).reduce((a, b) => a + b, 0) /
|
||||
perViewSumSquareReprojectionError.length
|
||||
);
|
||||
|
||||
format.horizontalFOV =
|
||||
2 * Math.atan2(format.resolution.width / 2, calib.cameraIntrinsics.data[0]) * (180 / Math.PI);
|
||||
format.verticalFOV =
|
||||
2 * Math.atan2(format.resolution.height / 2, calib.cameraIntrinsics.data[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
|
||||
(format.resolution.height / (calib.cameraIntrinsics.data[4] / calib.cameraIntrinsics.data[0])) ** 2
|
||||
) / 2,
|
||||
calib.intrinsics[0]
|
||||
calib.cameraIntrinsics.data[0]
|
||||
) *
|
||||
(180 / Math.PI);
|
||||
}
|
||||
@@ -55,7 +58,7 @@ const getUniqueVideoResolutions = (): VideoFormat[] => {
|
||||
return uniqueResolutions;
|
||||
};
|
||||
const getUniqueVideoResolutionStrings = (): { name: string; value: number }[] =>
|
||||
getUniqueVideoResolutions().map<{ name: string; value: number }>((f) => ({
|
||||
getUniqueVideoFormatsByResolution().map<{ name: string; value: number }>((f) => ({
|
||||
name: `${getResolutionString(f.resolution)}`,
|
||||
// Index won't ever be undefined
|
||||
value: f.index || 0
|
||||
@@ -150,7 +153,7 @@ const importCalibrationFromCalibDB = ref();
|
||||
const openCalibUploadPrompt = () => {
|
||||
importCalibrationFromCalibDB.value.click();
|
||||
};
|
||||
const readImportedCalibration = () => {
|
||||
const readImportedCalibrationFromCalibDB = () => {
|
||||
const files = importCalibrationFromCalibDB.value.files;
|
||||
if (files.length === 0) return;
|
||||
|
||||
@@ -214,6 +217,13 @@ const endCalibration = () => {
|
||||
isCalibrating.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
let showCalDialog = ref(false);
|
||||
let selectedVideoFormat = ref<VideoFormat | undefined>(undefined);
|
||||
const setSelectedVideoFormat = (format: VideoFormat) => {
|
||||
selectedVideoFormat.value = format;
|
||||
showCalDialog.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -234,7 +244,12 @@ const endCalibration = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(value, index) in getUniqueVideoResolutions()" :key="index">
|
||||
<tr
|
||||
v-for="(value, index) in getUniqueVideoFormatsByResolution()"
|
||||
:key="index"
|
||||
title="Click to get calibration specific information"
|
||||
@click="setSelectedVideoFormat(value)"
|
||||
>
|
||||
<td>{{ getResolutionString(value.resolution) }}</td>
|
||||
<td>
|
||||
{{ value.mean !== undefined ? (isNaN(value.mean) ? "NaN" : value.mean.toFixed(2) + "px") : "-" }}
|
||||
@@ -429,7 +444,7 @@ const endCalibration = () => {
|
||||
type="file"
|
||||
accept=".json"
|
||||
style="display: none"
|
||||
@change="readImportedCalibration"
|
||||
@change="readImportedCalibrationFromCalibDB"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -478,6 +493,9 @@ const endCalibration = () => {
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
<v-dialog v-model="showCalDialog" width="80em">
|
||||
<CameraCalibrationInfoCard v-if="selectedVideoFormat" :video-format="selectedVideoFormat" />
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -494,6 +512,7 @@ const endCalibration = () => {
|
||||
|
||||
tbody :hover td {
|
||||
background-color: #005281 !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
<script setup lang="ts">
|
||||
import type { BoardObservation, CameraCalibrationResult, VideoFormat } from "@/types/SettingTypes";
|
||||
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
|
||||
import { useStateStore } from "@/stores/StateStore";
|
||||
import { ref } from "vue";
|
||||
import loadingImage from "@/assets/images/loading.svg";
|
||||
import { getResolutionString, parseJsonFile } from "@/lib/PhotonUtils";
|
||||
|
||||
const props = defineProps<{
|
||||
videoFormat: VideoFormat;
|
||||
}>();
|
||||
|
||||
const getMeanFromView = (o: BoardObservation) => {
|
||||
// Is this the right formula for RMS error? who knows! not me!
|
||||
const perViewSumSquareReprojectionError = o.reprojectionErrors.flatMap((it2) => [it2.x, it2.y]);
|
||||
|
||||
// For each error, square it, sum the squares, and divide by total points N
|
||||
return Math.sqrt(
|
||||
perViewSumSquareReprojectionError.map((it) => Math.pow(it, 2)).reduce((a, b) => a + b, 0) /
|
||||
perViewSumSquareReprojectionError.length
|
||||
);
|
||||
};
|
||||
|
||||
// Import and export functions
|
||||
const downloadCalibration = () => {
|
||||
const calibData = useCameraSettingsStore().getCalibrationCoeffs(props.videoFormat.resolution);
|
||||
if (calibData === undefined) {
|
||||
useStateStore().showSnackbarMessage({
|
||||
color: "error",
|
||||
message:
|
||||
"Calibration data isn't available for the requested resolution, please calibrate the requested resolution first"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const camUniqueName = useCameraSettingsStore().currentCameraSettings.uniqueName;
|
||||
const filename = `photon_calibration_${camUniqueName}_${calibData.resolution.width}x${calibData.resolution.height}.json`;
|
||||
const fileData = JSON.stringify(calibData);
|
||||
|
||||
const element = document.createElement("a");
|
||||
element.style.display = "none";
|
||||
element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(fileData));
|
||||
element.setAttribute("download", filename);
|
||||
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
};
|
||||
const importCalibrationFromPhotonJson = ref();
|
||||
const openUploadPhotonCalibJsonPrompt = () => {
|
||||
importCalibrationFromPhotonJson.value.click();
|
||||
};
|
||||
const importCalibration = async () => {
|
||||
const files = importCalibrationFromPhotonJson.value.files;
|
||||
if (files.length === 0) return;
|
||||
const uploadedJson = files[0];
|
||||
|
||||
const data = await parseJsonFile<CameraCalibrationResult>(uploadedJson);
|
||||
|
||||
if (
|
||||
data.resolution.height != props.videoFormat.resolution.height ||
|
||||
data.resolution.width != props.videoFormat.resolution.width
|
||||
) {
|
||||
useStateStore().showSnackbarMessage({
|
||||
color: "error",
|
||||
message: `The resolution of the calibration export doesn't match the current resolution ${props.videoFormat.resolution.height}x${props.videoFormat.resolution.width}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
useCameraSettingsStore()
|
||||
.importCalibrationFromData({ calibration: data })
|
||||
.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."
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
interface ObservationDetails {
|
||||
snapshotSrc: any;
|
||||
mean: number;
|
||||
index: number;
|
||||
}
|
||||
const getObservationDetails = (): ObservationDetails[] | undefined => {
|
||||
return getCalibrationCoeffs()?.observations.map((o, i) => ({
|
||||
index: i,
|
||||
mean: parseFloat(getMeanFromView(o).toFixed(2)),
|
||||
snapshotSrc: o.includeObservationInCalibration ? "data:image/png;base64," + o.snapshotData.data : loadingImage
|
||||
}));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card color="primary" class="pa-6" dark>
|
||||
<v-row>
|
||||
<v-col cols="12" md="5">
|
||||
<v-card-title class="pl-0 ml-0"
|
||||
><span class="text-no-wrap" style="white-space: pre !important">Calibration Details: </span
|
||||
><span class="text-no-wrap"
|
||||
>{{ useCameraSettingsStore().currentCameraName }}@{{ getResolutionString(videoFormat.resolution) }}</span
|
||||
></v-card-title
|
||||
>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn color="secondary" class="mt-4" style="width: 100%" @click="openUploadPhotonCalibJsonPrompt">
|
||||
<v-icon left> mdi-import</v-icon>
|
||||
<span>Import</span>
|
||||
</v-btn>
|
||||
<input
|
||||
ref="importCalibrationFromPhotonJson"
|
||||
type="file"
|
||||
accept=".json"
|
||||
style="display: none"
|
||||
@change="importCalibration"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn
|
||||
color="secondary"
|
||||
class="mt-4"
|
||||
:disabled="getCalibrationCoeffs() === undefined"
|
||||
style="width: 100%"
|
||||
@click="downloadCalibration"
|
||||
>
|
||||
<v-icon left>mdi-export</v-icon>
|
||||
<span>Export</span>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="getCalibrationCoeffs() !== undefined" class="pt-2">
|
||||
<v-card-subtitle>Calibration Details</v-card-subtitle>
|
||||
<v-simple-table dense style="width: 100%" class="pl-2 pr-2">
|
||||
<template #default>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left">Name</th>
|
||||
<th class="text-left">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Fx</td>
|
||||
<td>{{ getCalibrationCoeffs()?.cameraIntrinsics.data[0].toFixed(2) || 0.0 }} mm</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Fy</td>
|
||||
<td>{{ getCalibrationCoeffs()?.cameraIntrinsics.data[4].toFixed(2) || 0.0 }} mm</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cx</td>
|
||||
<td>{{ getCalibrationCoeffs()?.cameraIntrinsics.data[2].toFixed(2) || 0.0 }} px</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cy</td>
|
||||
<td>{{ getCalibrationCoeffs()?.cameraIntrinsics.data[5].toFixed(2) || 0.0 }} px</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Distortion</td>
|
||||
<td>{{ getCalibrationCoeffs()?.distCoeffs.data.map((it) => parseFloat(it.toFixed(3))) || [] }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Mean Err</td>
|
||||
<td>
|
||||
{{
|
||||
videoFormat.mean !== undefined
|
||||
? isNaN(videoFormat.mean)
|
||||
? "NaN"
|
||||
: videoFormat.mean.toFixed(2) + "px"
|
||||
: "-"
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Horizontal FOV</td>
|
||||
<td>{{ videoFormat.horizontalFOV !== undefined ? videoFormat.horizontalFOV.toFixed(2) + "°" : "-" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Vertical FOV</td>
|
||||
<td>{{ videoFormat.verticalFOV !== undefined ? videoFormat.verticalFOV.toFixed(2) + "°" : "-" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Diagonal FOV</td>
|
||||
<td>{{ videoFormat.diagonalFOV !== undefined ? videoFormat.diagonalFOV.toFixed(2) + "°" : "-" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</template>
|
||||
</v-simple-table>
|
||||
<hr style="width: 100%" class="ma-6" />
|
||||
<v-card-subtitle>Per Observation Details</v-card-subtitle>
|
||||
<v-data-table
|
||||
dense
|
||||
style="width: 100%"
|
||||
class="pl-2 pr-2"
|
||||
:headers="[
|
||||
{ text: 'Observation Id', value: 'index' },
|
||||
{ text: 'Mean Reprojection Error', value: 'mean' }
|
||||
]"
|
||||
:items="getObservationDetails()"
|
||||
item-key="index"
|
||||
show-expand
|
||||
expand-icon="mdi-eye"
|
||||
>
|
||||
<template #expanded-item="{ headers, item }">
|
||||
<td :colspan="headers.length">
|
||||
<div style="display: flex; justify-content: center; width: 100%">
|
||||
<img :src="item.snapshotSrc" alt="observation image" class="snapshot-preview pt-2 pb-2" />
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-row>
|
||||
<v-row v-else class="pt-2 mb-0 pb-0">
|
||||
The selected video format doesn't have any additional information as it has yet to be calibrated.
|
||||
</v-row>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.v-data-table {
|
||||
background-color: #006492 !important;
|
||||
}
|
||||
.snapshot-preview {
|
||||
max-width: 55%;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 512px) {
|
||||
.snapshot-preview {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -41,7 +41,12 @@ const fpsTooLow = computed<boolean>(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card class="mb-3 pb-3 pa-4" color="primary" dark>
|
||||
<v-card
|
||||
id="camera-settings-camera-view-card"
|
||||
class="camera-settings-camera-view-card mb-3 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"
|
||||
|
||||
Reference in New Issue
Block a user