Files
PhotonVision/photon-client/src/components/app/photon-calibration-visualizer.vue
2026-02-02 08:06:48 -08:00

372 lines
10 KiB
Vue

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch, watchEffect, type Ref } from "vue";
const {
AmbientLight,
AxesHelper,
BoxGeometry,
CameraHelper,
Color,
ConeGeometry,
Group,
Mesh,
MeshNormalMaterial,
MeshPhongMaterial,
PerspectiveCamera,
Scene,
SphereGeometry,
WebGLRenderer
} = await import("three");
const { TrackballControls } = await import("three/examples/jsm/controls/TrackballControls");
import type { BoardObservation, CameraCalibrationResult } from "@/types/SettingTypes";
import axios from "axios";
import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { useTheme } from "vuetify";
import { createPerspectiveCamera } from "@/lib/ThreeUtils";
const theme = useTheme();
const props = defineProps<{
cameraUniqueName: string;
resolution: { width: number; height: number };
title: string;
}>();
let scene: Scene | undefined;
let camera: PerspectiveCamera | undefined;
let renderer: WebGLRenderer | undefined;
let controls: TrackballControls | undefined;
const createChessboard = (obs: BoardObservation, cal: CameraCalibrationResult): Group => {
const group = new Group();
if (obs.locationInImageSpace.length === 0) return group;
// Add corner spheres
obs.locationInObjectSpace.forEach((corner, idx) => {
if (corner.x < 0 || corner.y < 0) return;
const isOutlier = !obs.cornersUsed[idx];
const color = isOutlier ? 0xff3333 : 0x33ff33;
const sphereGeom = new SphereGeometry(cal.calobjectSpacing / 8, 8, 8);
const sphereMat = new MeshPhongMaterial({
color: color,
opacity: 1,
transparent: !isOutlier
});
const sphere = new Mesh(sphereGeom, sphereMat);
sphere.position.set(corner.x, corner.y, corner.z);
group.add(sphere);
});
return group;
};
let previousTargets: Object3D[] = [];
let baseAspect: number | undefined;
const drawCalibration = async (cal: CameraCalibrationResult | null) => {
// Check here, since if we check in watchEffect this never gets called
if (!cal || !scene || !camera || !renderer || !controls) {
return;
}
scene.remove(...previousTargets);
previousTargets = [];
// Draw all chessboards with transparency
cal.observations.forEach((obs) => {
const pose = obs.optimisedCameraToObject;
// Create chessboard
const board = createChessboard(obs, cal);
board.userData.isCalibrationObject = true;
// Apply transform from camera to chessboard
const pos = pose.translation;
board.position.set(pos.x, pos.y, pos.z);
if (pose.rotation.quaternion) {
const q = pose.rotation.quaternion;
board.quaternion.set(q.X, q.Y, q.Z, q.W);
}
previousTargets.push(board);
});
// And show camera frustum
const calibCamera = await createPerspectiveCamera(props.resolution, cal.cameraIntrinsics);
const helper = new CameraHelper(calibCamera);
// Flip to +Z forward
const helperGroup = new Group();
helperGroup.add(helper);
helperGroup.rotateY(Math.PI);
previousTargets.push(helperGroup);
if (previousTargets.length > 0) {
scene.add(...previousTargets);
}
};
const calibrationData: Ref<CameraCalibrationResult | null> = ref(null);
const isLoading: Ref<boolean> = ref(true);
const error: Ref<string | null> = ref(null);
const fetchCalibrationData = async () => {
isLoading.value = true;
error.value = null;
try {
const response = await axios.get("/settings/camera/getCalibration", {
params: {
cameraUniqueName: props.cameraUniqueName,
width: props.resolution.width,
height: props.resolution.height
}
});
calibrationData.value = response.data;
} catch (err) {
console.error("Failed to fetch calibration data:", err);
error.value = "Failed to load calibration data";
} finally {
isLoading.value = false;
}
};
const onWindowResize = () => {
const container = document.getElementById("container");
const canvas = document.getElementById("view");
if (!container || !canvas || !camera || !renderer) {
return;
}
// Compute a concrete width from the container and derive height from a
// stable base aspect ratio (calculated on mount) to avoid feedback loops
// where updating canvas size changes container size while resizing
const width = Math.max(1, Math.floor(container.clientWidth));
let height: number;
if (baseAspect && baseAspect > 0) {
height = Math.max(1, Math.floor(width / baseAspect));
} else {
height = Math.max(1, Math.floor(container.clientHeight));
}
// Use updateStyle=false so Three.js does not write to canvas style,
// which can affect layout and re-trigger resize events
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
};
const resetCamFirstPerson = () => {
if (!scene || !camera || !controls) {
return;
}
controls.reset();
camera.position.set(0, 0, 0.05);
camera.up.set(0, -1, 0);
controls.target.set(0.0, 0.0, 1.0);
controls.update();
if (previousTargets.length > 0) {
scene.add(...previousTargets);
}
};
const resetCamThirdPerson = () => {
if (!scene || !camera || !controls) {
return;
}
controls.reset();
camera.position.set(-0.3, -0.2, -0.3);
camera.up.set(0, -1, 0);
controls.target.set(0.0, 0.0, 0.4);
controls.update();
if (previousTargets.length > 0) {
scene.add(...previousTargets);
}
};
let animationFrameId: number | null = null;
onMounted(async () => {
// Grab data first off
fetchCalibrationData();
scene = new Scene();
camera = new PerspectiveCamera(75, 800 / 800, 0.1, 1000);
const canvas = document.getElementById("view");
if (!canvas) return;
renderer = new WebGLRenderer({ canvas: canvas });
// Add lights
const ambientLight = new AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
if (theme.global.name.value === "LightTheme") scene.background = new Color(0xa9a9a9);
else scene.background = new Color(0x000000);
// Initialize a stable aspect ratio so subsequent resize events derive
// height from width, avoiding layout feedback during continuous resizing
try {
const initWidth = Math.max(1, Math.floor(document.getElementById("container")?.clientWidth || 1));
const initHeight = Math.max(1, Math.floor(document.getElementById("container")?.clientHeight || 1));
baseAspect = initWidth / Math.max(1, initHeight);
} catch {
baseAspect = undefined;
}
onWindowResize();
window.addEventListener("resize", onWindowResize);
const referenceFrameCues: Object3D[] = [];
// Draw the reference frame
referenceFrameCues.push(new AxesHelper(0.3));
// Draw the Camera Body
const camSize = 0.04;
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.rotateX(-Math.PI / 2);
camLens.position.set(0, 0, camSize * 0.8);
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 || !camera || !renderer || !controls) {
return;
}
animationFrameId = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
});
const cleanup = () => {
window.removeEventListener("resize", onWindowResize);
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
if (controls) {
controls.dispose();
}
if (renderer) {
renderer.dispose();
renderer.forceContextLoss();
}
if (scene) {
scene.traverse((object) => {
if (object instanceof Mesh) {
object.geometry?.dispose();
if (object.material) {
if (Array.isArray(object.material)) {
object.material.forEach((material) => material.dispose());
} else {
object.material.dispose();
}
}
}
});
}
scene = undefined;
camera = undefined;
renderer = undefined;
controls = undefined;
previousTargets = [];
};
onBeforeUnmount(cleanup);
// If hot-reloading, cleanup on hot reload
if (import.meta.hot) {
import.meta.hot.dispose(() => {
cleanup();
});
}
watchEffect(() => {
drawCalibration(calibrationData.value);
});
watch(
() => [
props.cameraUniqueName,
props.resolution.width,
props.resolution.height,
useCameraSettingsStore().getCalibrationCoeffs(props.resolution)
],
() => {
console.log("Camera or resolution changed, refetching calibration");
fetchCalibrationData();
}
);
</script>
<template>
<div style="width: 100%; height: 100%" class="d-flex flex-column">
<div class="d-flex flex-wrap pt-0 pb-2">
<v-col cols="12" md="6" class="pl-0">
<v-card-title class="pa-0">
{{ props.title }}
</v-card-title>
</v-col>
<v-col cols="6" md="3" class="d-flex align-center pt-0 pt-md-3 pl-6 pl-md-3">
<v-btn
style="width: 100%"
color="buttonActive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="resetCamFirstPerson"
>
First Person
</v-btn>
</v-col>
<v-col cols="6" md="3" class="d-flex align-center pt-0 pt-md-3 pr-0">
<v-btn
style="width: 100%"
color="buttonActive"
:variant="theme.global.name.value === 'LightTheme' ? 'elevated' : 'outlined'"
@click="resetCamThirdPerson"
>
Third Person
</v-btn>
</v-col>
</div>
<div id="container" style="flex: 1 1 auto">
<canvas id="view" class="w-100 h-100" />
</div>
</div>
</template>