Show board outliers in calibration info card (#1267)

This commit is contained in:
Matt Morley
2025-12-26 21:20:36 -05:00
committed by GitHub
parent 235e601cbc
commit fddff5dbca
17 changed files with 1062 additions and 348 deletions

View File

@@ -38,7 +38,8 @@ const getUniqueVideoFormatsByResolution = (): VideoFormat[] => {
if (!skip) {
const calib = useCameraSettingsStore().getCalibrationCoeffs(format.resolution);
if (calib !== undefined) {
// For each error, square it, sum the squares, and divide by total points N
// Mean overall reprojection error
// Calculated as average of each observation's mean error
if (calib.meanErrors.length)
format.mean = calib.meanErrors.reduce((a, b) => a + b, 0) / calib.meanErrors.length;
else format.mean = NaN;
@@ -249,27 +250,31 @@ const setSelectedVideoFormat = (format: VideoFormat) => {
<th>Horizontal FOV</th>
<th>Vertical FOV</th>
<th>Diagonal FOV</th>
<th>Info</th>
</tr>
</thead>
<tbody style="cursor: pointer">
<tr v-for="(value, index) in getUniqueVideoFormatsByResolution()" :key="index">
<td>{{ getResolutionString(value.resolution) }}</td>
<td>
{{ value.mean !== undefined ? (isNaN(value.mean) ? "Unknown" : value.mean.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>
<v-tooltip location="bottom">
<template #activator="{ props }">
<td v-bind="props" @click="setSelectedVideoFormat(value)">
<v-icon size="small" color="primary">mdi-information</v-icon>
<v-tooltip
v-for="(value, index) in getUniqueVideoFormatsByResolution()"
:key="index"
transition=""
location="bottom"
:open-delay="100"
>
<template #activator="{ props }">
<tr :key="index" v-bind="props" @click="setSelectedVideoFormat(value)">
<td>{{ getResolutionString(value.resolution) }}</td>
<td>
{{
value.mean !== undefined ? (isNaN(value.mean) ? "Unknown" : value.mean.toFixed(2) + "px") : "-"
}}
</td>
</template>
<span>View calibration information</span>
</v-tooltip>
</tr>
<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>
</template>
<span>View calibration information</span>
</v-tooltip>
</tbody>
</v-table>
</v-card-text>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import PhotonCalibrationVisualizer from "@/components/app/photon-calibration-visualizer.vue";
import type { CameraCalibrationResult, VideoFormat } from "@/types/SettingTypes";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { useStateStore } from "@/stores/StateStore";
@@ -8,7 +9,6 @@ import { useTheme } from "vuetify";
import PvDeleteModal from "@/components/common/pv-delete-modal.vue";
const theme = useTheme();
const props = defineProps<{
videoFormat: VideoFormat;
}>();
@@ -79,8 +79,10 @@ const importCalibration = async () => {
};
interface ObservationDetails {
mean: number;
index: number;
mean: number;
numOutliers: number;
numMissing: number;
}
const currentCalibrationCoeffs = computed<CameraCalibrationResult | undefined>(() =>
@@ -92,7 +94,9 @@ const getObservationDetails = (): ObservationDetails[] | undefined => {
return coefficients?.meanErrors.map((m, i) => ({
index: i,
mean: parseFloat(m.toFixed(2))
mean: parseFloat(m.toFixed(2)),
numOutliers: coefficients.numOutliers[i],
numMissing: coefficients.numMissing[i]
}));
};
@@ -101,213 +105,236 @@ const exportCalibrationURL = computed<string>(() =>
);
const calibrationImageURL = (index: number) =>
useCameraSettingsStore().getCalImageUrl(inject<string>("backendHost") as string, props.videoFormat.resolution, index);
const tab = ref("details");
const viewingImg = ref(0);
</script>
<template>
<v-card color="surface" dark>
<div class="d-flex flex-wrap pt-2 pl-2 pr-2 align-center">
<v-col cols="12" md="6">
<v-card-title class="pa-0"> Calibration Details </v-card-title>
</v-col>
<v-col cols="12" md="6" class="d-flex align-center pt-0 pt-md-3">
<v-btn
color="buttonPassive"
class="mr-2"
style="flex: 1"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="openUploadPhotonCalibJsonPrompt"
>
<v-icon start size="large">mdi-import</v-icon>
<span>Import</span>
</v-btn>
<input
ref="importCalibrationFromPhotonJson"
type="file"
accept=".json"
style="display: none"
@change="importCalibration"
/>
<v-btn
color="buttonPassive"
class="mr-2"
:disabled="!currentCalibrationCoeffs"
style="flex: 1"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="openExportCalibrationPrompt"
>
<v-icon start size="large">mdi-export</v-icon>
<span>Export</span>
</v-btn>
<a
ref="exportCalibration"
style="color: black; text-decoration: none; display: none"
:href="exportCalibrationURL"
target="_blank"
/>
<v-btn
color="error"
:disabled="!currentCalibrationCoeffs"
style="flex: 1"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="() => (confirmRemoveDialog = { show: true, vf: props.videoFormat })"
>
<v-icon start size="large">mdi-delete</v-icon>
<span>Delete</span>
</v-btn>
</v-col>
</div>
<v-card-title class="pt-0 pb-0"
>{{ useCameraSettingsStore().currentCameraName }}@{{ getResolutionString(videoFormat.resolution) }}</v-card-title
>
<v-card-text v-if="!currentCalibrationCoeffs">
<v-alert
class="pt-3 pb-3"
color="primary"
density="compact"
text="The selected video format has not been calibrated."
icon="mdi-alert-circle-outline"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'tonal'"
/>
</v-card-text>
<v-card-text class="pt-0">
<v-table density="compact" style="width: 100%">
<template #default>
<thead>
<tr>
<th class="text-left">Name</th>
<th class="text-left">Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Fx</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.cameraIntrinsics.data[0].toFixed(2) || 0.0
}}
mm
</td>
</tr>
<tr>
<td>Fy</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.cameraIntrinsics.data[4].toFixed(2) || 0.0
}}
mm
</td>
</tr>
<tr>
<td>Cx</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.cameraIntrinsics.data[2].toFixed(2) || 0.0
}}
px
</td>
</tr>
<tr>
<td>Cy</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.cameraIntrinsics.data[5].toFixed(2) || 0.0
}}
px
</td>
</tr>
<tr>
<td>Distortion</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.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>
<!-- Board warp, only shown for mrcal-calibrated cameras -->
<tr v-if="currentCalibrationCoeffs?.calobjectWarp?.length === 2">
<td>Board warp, X/Y</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.calobjectWarp?.map((it) => (it * 1000).toFixed(2) + " mm")
.join(" / ")
}}
</td>
</tr>
</tbody>
</template>
</v-table>
</v-card-text>
<v-card-title v-if="currentCalibrationCoeffs" class="pt-0 pb-0">Individual Observations</v-card-title>
<v-card-text v-if="currentCalibrationCoeffs" class="pt-0">
<v-data-table
density="compact"
style="width: 100%"
:headers="[
{ title: 'Observation Id', key: 'index' },
{ title: 'Mean Reprojection Error', key: 'mean' },
{ title: '', key: 'data-table-expand' }
]"
:items="getObservationDetails()"
item-value="index"
show-expand
>
<template #item.data-table-expand="{ internalItem, toggleExpand }">
<v-card-title class="pb-2">
<div class="d-flex flex-wrap">
<v-col cols="12" md="6" class="pa-0">
<v-card-title class="pa-0"> Calibration Details </v-card-title>
</v-col>
<v-col cols="6" md="3" class="d-flex align-center pt-0 pb-0 pl-0">
<v-btn
icon="mdi-eye"
class="text-none"
color="medium-emphasis"
size="small"
variant="text"
slim
@click="toggleExpand(internalItem)"
></v-btn>
</template>
color="buttonPassive"
style="width: 100%"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="openUploadPhotonCalibJsonPrompt"
>
<v-icon start size="large"> 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 cols="6" md="3" class="d-flex align-center pt-0 pb-0 pr-0">
<v-btn
color="buttonPassive"
:disabled="!currentCalibrationCoeffs"
style="width: 100%"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="openExportCalibrationPrompt"
>
<v-icon start size="large">mdi-export</v-icon>
<span>Export</span>
</v-btn>
<a
ref="exportCalibration"
style="color: black; text-decoration: none; display: none"
:href="exportCalibrationURL"
target="_blank"
/>
</v-col>
</div>
</v-card-title>
<template #expanded-row="{ columns, item }">
<td :colspan="columns.length">
<div style="display: flex; justify-content: center; width: 100%">
<img :src="calibrationImageURL(item.index)" alt="observation image" class="snapshot-preview pt-2 pb-2" />
</div>
</td>
</template>
</v-data-table>
<v-card-text class="d-flex flex-row pt-0">
<v-col cols="4" class="pa-0">
<v-tabs v-model="tab" grow bg-color="surface" height="48" slider-color="buttonActive">
<v-tab key="details" value="details">Details</v-tab>
<v-tab key="observations" value="observations">Observations</v-tab>
</v-tabs>
<v-tabs-window v-model="tab" class="pt-3">
<v-tabs-window-item key="details" value="details">
<v-table style="width: 100%" density="compact">
<template #default>
<tbody>
<tr>
<td>Camera</td>
<td>
{{ useCameraSettingsStore().currentCameraName }}
</td>
</tr>
<tr>
<td>Resolution</td>
<td>
{{ getResolutionString(videoFormat.resolution) }}
</td>
</tr>
<tr>
<td>Fx</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.cameraIntrinsics.data[0].toFixed(2) || 0.0
}}
mm
</td>
</tr>
<tr>
<td>Fy</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.cameraIntrinsics.data[4].toFixed(2) || 0.0
}}
mm
</td>
</tr>
<tr>
<td>Cx</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.cameraIntrinsics.data[2].toFixed(2) || 0.0
}}
px
</td>
</tr>
<tr>
<td>Cy</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.cameraIntrinsics.data[5].toFixed(2) || 0.0
}}
px
</td>
</tr>
<tr>
<td>Distortion</td>
<td>
{{
useCameraSettingsStore()
.getCalibrationCoeffs(props.videoFormat.resolution)
?.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>
<!-- Board warp, only shown for mrcal-calibrated cameras -->
<tr v-if="currentCalibrationCoeffs?.calobjectWarp?.length === 2">
<td>Board warp, X/Y</td>
<td>
{{
currentCalibrationCoeffs?.calobjectWarp?.map((it) => (it * 1000).toFixed(2) + " mm").join(" / ")
}}
</td>
</tr>
</tbody>
</template>
</v-table>
</v-tabs-window-item>
<v-tabs-window-item key="observations" value="observations">
<v-data-table
id="observations-table"
items-per-page-text="Page size:"
density="compact"
style="width: 100%"
:headers="[
{ title: 'Id', key: 'index' },
{ title: 'Mean Reprojection Error', key: 'mean' }
]"
:items="getObservationDetails()"
item-value="index"
show-expand
>
<template #item.data-table-expand="{ internalItem }">
<v-btn
class="text-none"
size="small"
variant="text"
slim
rounded
@click="viewingImg = internalItem.index"
>
<v-icon
size="large"
:color="viewingImg === internalItem.index ? 'buttonActive' : 'rgba(255, 255, 255, 0.7)'"
>mdi-eye</v-icon
>
</v-btn>
</template>
</v-data-table>
</v-tabs-window-item>
</v-tabs-window>
</v-col>
<v-col cols="8" class="pa-0 pl-6">
<v-card-text class="pa-0 fill-height d-flex justify-center align-center">
<div v-if="!currentCalibrationCoeffs">
<v-alert
class="pt-3 pb-3"
color="primary"
text="The selected video format has not been calibrated."
icon="mdi-alert-circle-outline"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'tonal'"
/>
</div>
<Suspense v-else-if="tab === 'details'">
<!-- Allows us to import three js when it's actually needed -->
<PhotonCalibrationVisualizer
:camera-unique-name="useCameraSettingsStore().currentCameraSettings.uniqueName"
:resolution="props.videoFormat.resolution"
title="Camera to Board Transforms"
/>
<template #fallback> Loading... </template>
</Suspense>
<div v-else style="display: flex; justify-content: center; width: 100%">
<img :src="calibrationImageURL(viewingImg)" alt="observation image" class="snapshot-preview pt-2 pb-2" />
</div>
</v-card-text>
</v-col>
</v-card-text>
</v-card>
@@ -322,11 +349,7 @@ const calibrationImageURL = (index: number) =>
<style scoped>
.snapshot-preview {
max-width: 55%;
}
@media only screen and (max-width: 512px) {
.snapshot-preview {
max-width: 100%;
}
max-width: 100%;
max-height: 100%;
}
</style>