diff --git a/packages/studio-base/src/panels/ThreeDeeRender/foxglove.ts b/packages/studio-base/src/panels/ThreeDeeRender/foxglove.ts index 8f9b280db9..d5234dde2a 100644 --- a/packages/studio-base/src/panels/ThreeDeeRender/foxglove.ts +++ b/packages/studio-base/src/panels/ThreeDeeRender/foxglove.ts @@ -8,6 +8,15 @@ addFoxgloveSchema(FRAME_TRANSFORM_DATATYPES, "foxglove.FrameTransform"); export const POINTCLOUD_DATATYPES = new Set(); addFoxgloveSchema(POINTCLOUD_DATATYPES, "foxglove.PointCloud"); +export const RAW_IMAGE_DATATYPES = new Set(); +addFoxgloveSchema(RAW_IMAGE_DATATYPES, "foxglove.RawImage"); + +export const COMPRESSED_IMAGE_DATATYPES = new Set(); +addFoxgloveSchema(COMPRESSED_IMAGE_DATATYPES, "foxglove.CompressedImage"); + +export const CAMERA_CALIBRATION_DATATYPES = new Set(); +addFoxgloveSchema(CAMERA_CALIBRATION_DATATYPES, "foxglove.CameraCalibration"); + export const SCENE_UPDATE_DATATYPES = new Set(); addFoxgloveSchema(SCENE_UPDATE_DATATYPES, "foxglove.SceneUpdate"); diff --git a/packages/studio-base/src/panels/ThreeDeeRender/renderables/Cameras.ts b/packages/studio-base/src/panels/ThreeDeeRender/renderables/Cameras.ts index f1e425a233..05728d72bb 100644 --- a/packages/studio-base/src/panels/ThreeDeeRender/renderables/Cameras.ts +++ b/packages/studio-base/src/panels/ThreeDeeRender/renderables/Cameras.ts @@ -5,6 +5,7 @@ import { PinholeCameraModel } from "@foxglove/den/image"; import Logger from "@foxglove/log"; import { toNanoSec } from "@foxglove/rostime"; +import { CameraCalibration } from "@foxglove/schemas/schemas/typescript"; import { SettingsTreeAction, SettingsTreeFields } from "@foxglove/studio"; import type { RosValue } from "@foxglove/studio-base/players/types"; import { MutablePoint } from "@foxglove/studio-base/types/Messages"; @@ -14,10 +15,11 @@ import { Renderer } from "../Renderer"; import { PartialMessage, PartialMessageEvent, SceneExtension } from "../SceneExtension"; import { SettingsTreeEntry } from "../SettingsManager"; import { makeRgba, rgbaToCssString, stringToRgba } from "../color"; -import { normalizeHeader } from "../normalizeMessages"; +import { CAMERA_CALIBRATION_DATATYPES } from "../foxglove"; +import { normalizeHeader, normalizeTime } from "../normalizeMessages"; import { CameraInfo, - CAMERA_INFO_DATATYPES, + CAMERA_INFO_DATATYPES as ROS_CAMERA_INFO_DATATYPES, IncomingCameraInfo, Marker, MarkerAction, @@ -61,6 +63,7 @@ export type CameraInfoUserData = BaseUserData & { settings: LayerSettingsCameraInfo; topic: string; cameraInfo: CameraInfo | undefined; + originalMessage: Record | undefined; cameraModel: PinholeCameraModel | undefined; lines: RenderableLineList | undefined; }; @@ -72,7 +75,7 @@ export class CameraInfoRenderable extends Renderable { } public override details(): Record { - return this.userData.cameraInfo ?? {}; + return this.userData.originalMessage ?? {}; } } @@ -80,7 +83,8 @@ export class Cameras extends SceneExtension { public constructor(renderer: Renderer) { super("foxglove.Cameras", renderer); - renderer.addDatatypeSubscriptions(CAMERA_INFO_DATATYPES, this.handleCameraInfo); + renderer.addDatatypeSubscriptions(ROS_CAMERA_INFO_DATATYPES, this.handleCameraInfo); + renderer.addDatatypeSubscriptions(CAMERA_CALIBRATION_DATATYPES, this.handleCameraInfo); } public override settingsNodes(): SettingsTreeEntry[] { @@ -88,7 +92,10 @@ export class Cameras extends SceneExtension { const handler = this.handleSettingsAction; const entries: SettingsTreeEntry[] = []; for (const topic of this.renderer.topics ?? []) { - if (CAMERA_INFO_DATATYPES.has(topic.datatype)) { + if ( + ROS_CAMERA_INFO_DATATYPES.has(topic.datatype) || + CAMERA_CALIBRATION_DATATYPES.has(topic.datatype) + ) { const config = (configTopics[topic.name] ?? {}) as Partial; // prettier-ignore @@ -125,17 +132,25 @@ export class Cameras extends SceneExtension { const topicName = path[1]!; const renderable = this.renderables.get(topicName); if (renderable) { - const { cameraInfo, receiveTime } = renderable.userData; + const { cameraInfo, receiveTime, originalMessage } = renderable.userData; if (cameraInfo) { const settings = this.renderer.config.topics[topicName] as | Partial | undefined; - this._updateCameraInfoRenderable(renderable, cameraInfo, receiveTime, settings); + this._updateCameraInfoRenderable( + renderable, + cameraInfo, + originalMessage, + receiveTime, + settings, + ); } } }; - private handleCameraInfo = (messageEvent: PartialMessageEvent): void => { + private handleCameraInfo = ( + messageEvent: PartialMessageEvent, + ): void => { const topic = messageEvent.topic; const cameraInfo = normalizeCameraInfo(messageEvent.message); const receiveTime = toNanoSec(messageEvent.receiveTime); @@ -160,6 +175,7 @@ export class Cameras extends SceneExtension { settings, topic, cameraInfo: undefined, + originalMessage: undefined, cameraModel: undefined, lines: undefined, }); @@ -171,6 +187,7 @@ export class Cameras extends SceneExtension { this._updateCameraInfoRenderable( renderable, cameraInfo, + messageEvent.message, receiveTime, renderable.userData.settings, ); @@ -179,6 +196,7 @@ export class Cameras extends SceneExtension { private _updateCameraInfoRenderable( renderable: CameraInfoRenderable, cameraInfo: CameraInfo, + originalMessage: Record | undefined, receiveTime: bigint, settings: Partial | undefined, ): void { @@ -200,6 +218,7 @@ export class Cameras extends SceneExtension { if (!dataEqual) { // log.warn(`CameraInfo changed on topic "${topic}", updating rectification model`); renderable.userData.cameraInfo = cameraInfo; + renderable.userData.originalMessage = originalMessage; if (cameraInfo.P.length === 12) { try { @@ -431,7 +450,9 @@ function normalizeRegionOfInterest( }; } -function normalizeCameraInfo(message: PartialMessage): CameraInfo { +function normalizeCameraInfo( + message: PartialMessage & PartialMessage, +): CameraInfo { // Handle lowercase field names as well (ROS2 compatibility) const D = message.D ?? message.d; const K = message.K ?? message.k; @@ -444,7 +465,10 @@ function normalizeCameraInfo(message: PartialMessage): Camer const Plen = P?.length ?? 0; return { - header: normalizeHeader(message.header), + header: + "timestamp" in message + ? { stamp: normalizeTime(message.timestamp), frame_id: message.frame_id ?? "" } + : normalizeHeader(message.header), height: message.height ?? 0, width: message.width ?? 0, distortion_model: message.distortion_model ?? "", diff --git a/packages/studio-base/src/panels/ThreeDeeRender/renderables/Images.ts b/packages/studio-base/src/panels/ThreeDeeRender/renderables/Images.ts index 6ae292a521..814e0383d6 100644 --- a/packages/studio-base/src/panels/ThreeDeeRender/renderables/Images.ts +++ b/packages/studio-base/src/panels/ThreeDeeRender/renderables/Images.ts @@ -21,6 +21,7 @@ import { } from "@foxglove/den/image"; import Logger from "@foxglove/log"; import { toNanoSec } from "@foxglove/rostime"; +import { CameraCalibration, CompressedImage, RawImage } from "@foxglove/schemas/schemas/typescript"; import { SettingsTreeAction, SettingsTreeFields } from "@foxglove/studio"; import type { RosValue } from "@foxglove/studio-base/players/types"; import { MutablePoint } from "@foxglove/studio-base/types/Messages"; @@ -30,13 +31,18 @@ import type { Renderer } from "../Renderer"; import { PartialMessage, PartialMessageEvent, SceneExtension } from "../SceneExtension"; import { SettingsTreeEntry } from "../SettingsManager"; import { stringToRgba } from "../color"; -import { normalizeByteArray, normalizeHeader } from "../normalizeMessages"; import { - CameraInfo, - Image, - CompressedImage, - IMAGE_DATATYPES, + CAMERA_CALIBRATION_DATATYPES, COMPRESSED_IMAGE_DATATYPES, + RAW_IMAGE_DATATYPES, +} from "../foxglove"; +import { normalizeByteArray, normalizeHeader, normalizeTime } from "../normalizeMessages"; +import { + CameraInfo, + Image as RosImage, + CompressedImage as RosCompressedImage, + IMAGE_DATATYPES as ROS_IMAGE_DATATYPES, + COMPRESSED_IMAGE_DATATYPES as ROS_COMPRESSED_IMAGE_DATATYPES, CAMERA_INFO_DATATYPES, } from "../ros"; import { BaseSettings, PRECISION_DISTANCE, SelectEntry } from "../settings"; @@ -46,6 +52,8 @@ import { CameraInfoUserData } from "./Cameras"; const log = Logger.getLogger(__filename); void log; +type AnyImage = RosImage | RosCompressedImage | RawImage | CompressedImage; + export type LayerSettingsImage = BaseSettings & { cameraInfoTopic: string | undefined; distance: number; @@ -69,7 +77,7 @@ const DEFAULT_SETTINGS: LayerSettingsImage = { export type ImageUserData = BaseUserData & { topic: string; settings: LayerSettingsImage; - image: Image | CompressedImage; + image: AnyImage; texture: THREE.Texture | undefined; material: THREE.MeshBasicMaterial | undefined; geometry: THREE.PlaneGeometry | undefined; @@ -102,13 +110,23 @@ export class Images extends SceneExtension { public constructor(renderer: Renderer) { super("foxglove.Images", renderer); - renderer.addDatatypeSubscriptions(IMAGE_DATATYPES, this.handleRawImage); - renderer.addDatatypeSubscriptions(COMPRESSED_IMAGE_DATATYPES, this.handleCompressedImage); + renderer.addDatatypeSubscriptions(ROS_IMAGE_DATATYPES, this.handleRosRawImage); + renderer.addDatatypeSubscriptions( + ROS_COMPRESSED_IMAGE_DATATYPES, + this.handleRosCompressedImage, + ); // Unconditionally subscribe to CameraInfo messages so the `foxglove.Cameras` extension will // always receive them and parse into camera models. This extension reuses the parsed camera // models from `foxglove.Cameras` renderer.addDatatypeSubscriptions(CAMERA_INFO_DATATYPES, { - handler: this.handleCameraInfo, + handler: this.handleRosCameraInfo, + forced: true, + }); + + renderer.addDatatypeSubscriptions(RAW_IMAGE_DATATYPES, this.handleRawImage); + renderer.addDatatypeSubscriptions(COMPRESSED_IMAGE_DATATYPES, this.handleCompressedImage); + renderer.addDatatypeSubscriptions(CAMERA_CALIBRATION_DATATYPES, { + handler: this.handleCameraCalibration, forced: true, }); } @@ -118,7 +136,12 @@ export class Images extends SceneExtension { const handler = this.handleSettingsAction; const entries: SettingsTreeEntry[] = []; for (const topic of this.renderer.topics ?? []) { - if (IMAGE_DATATYPES.has(topic.datatype) || COMPRESSED_IMAGE_DATATYPES.has(topic.datatype)) { + if ( + ROS_IMAGE_DATATYPES.has(topic.datatype) || + ROS_COMPRESSED_IMAGE_DATATYPES.has(topic.datatype) || + RAW_IMAGE_DATATYPES.has(topic.datatype) || + COMPRESSED_IMAGE_DATATYPES.has(topic.datatype) + ) { const config = (configTopics[topic.name] ?? {}) as Partial; // Build a list of all matching CameraInfo topics @@ -175,18 +198,25 @@ export class Images extends SceneExtension { } }; - private handleRawImage = (messageEvent: PartialMessageEvent): void => { - this.handleImage(messageEvent, normalizeImage(messageEvent.message)); + private handleRosRawImage = (messageEvent: PartialMessageEvent): void => { + this.handleImage(messageEvent, normalizeRosImage(messageEvent.message)); + }; + + private handleRosCompressedImage = ( + messageEvent: PartialMessageEvent, + ): void => { + this.handleImage(messageEvent, normalizeRosCompressedImage(messageEvent.message)); + }; + + private handleRawImage = (messageEvent: PartialMessageEvent): void => { + this.handleImage(messageEvent, normalizeRawImage(messageEvent.message)); }; private handleCompressedImage = (messageEvent: PartialMessageEvent): void => { this.handleImage(messageEvent, normalizeCompressedImage(messageEvent.message)); }; - private handleImage = ( - messageEvent: PartialMessageEvent, - image: Image | CompressedImage, - ): void => { + private handleImage = (messageEvent: PartialMessageEvent, image: AnyImage): void => { const topic = messageEvent.topic; const receiveTime = toNanoSec(messageEvent.receiveTime); @@ -199,8 +229,10 @@ export class Images extends SceneExtension { if (!renderable) { renderable = new ImageRenderable(topic, this.renderer, { receiveTime, - messageTime: toNanoSec(image.header.stamp), - frameId: this.renderer.normalizeFrameId(image.header.frame_id), + messageTime: toNanoSec("header" in image ? image.header.stamp : image.timestamp), + frameId: this.renderer.normalizeFrameId( + "header" in image ? image.header.frame_id : image.frame_id, + ), pose: makePose(), settingsPath: ["topics", topic], topic, @@ -241,7 +273,25 @@ export class Images extends SceneExtension { this._updateImageRenderable(renderable, image, receiveTime, renderable.userData.settings); }; - private handleCameraInfo = (messageEvent: PartialMessageEvent): void => { + private handleRosCameraInfo = (messageEvent: PartialMessageEvent): void => { + const topic = messageEvent.topic; + const updated = !this.cameraInfoTopics.has(topic); + this.cameraInfoTopics.add(topic); + + const renderable = this.renderables.get(topic); + if (renderable) { + const { image, settings } = renderable.userData; + this._updateImageRenderable(renderable, image, renderable.userData.receiveTime, settings); + } + + if (updated) { + this.updateSettingsTree(); + } + }; + + private handleCameraCalibration = ( + messageEvent: PartialMessageEvent, + ): void => { const topic = messageEvent.topic; const updated = !this.cameraInfoTopics.has(topic); this.cameraInfoTopics.add(topic); @@ -259,7 +309,7 @@ export class Images extends SceneExtension { private _updateImageRenderable( renderable: ImageRenderable, - image: Image | CompressedImage, + image: AnyImage, receiveTime: bigint, settings: Partial | undefined, ): void { @@ -272,9 +322,13 @@ export class Images extends SceneExtension { const topic = renderable.userData.topic; renderable.userData.image = image; - renderable.userData.frameId = this.renderer.normalizeFrameId(image.header.frame_id); + renderable.userData.frameId = this.renderer.normalizeFrameId( + "header" in image ? image.header.frame_id : image.frame_id, + ); renderable.userData.receiveTime = receiveTime; - renderable.userData.messageTime = toNanoSec(image.header.stamp); + renderable.userData.messageTime = toNanoSec( + "header" in image ? image.header.stamp : image.timestamp, + ); renderable.userData.settings = newSettings; // Dispose of the current geometry if the settings have changed @@ -313,9 +367,8 @@ export class Images extends SceneExtension { } // Create or update the bitmap texture - if ((image as Partial).format) { - const compressed = image as CompressedImage; - const bitmapData = new Blob([image.data], { type: `image/${compressed.format}` }); + if ("format" in image) { + const bitmapData = new Blob([image.data], { type: `image/${image.format}` }); self .createImageBitmap(bitmapData, { resizeWidth: DEFAULT_IMAGE_WIDTH }) .then((bitmap) => { @@ -342,8 +395,7 @@ export class Images extends SceneExtension { ); }); } else { - const raw = image as Image; - const { width, height } = raw; + const { width, height } = image; const prevTexture = renderable.userData.texture as THREE.DataTexture | undefined; if ( prevTexture == undefined || @@ -360,7 +412,7 @@ export class Images extends SceneExtension { } const texture = renderable.userData.texture as THREE.DataTexture; - rawImageToDataTexture(raw, {}, texture); + rawImageToDataTexture(image, {}, texture); texture.needsUpdate = true; } @@ -551,11 +603,12 @@ function autoSelectCameraInfoTopic( } function rawImageToDataTexture( - image: Image, + image: RosImage | RawImage, options: RawImageOptions, output: THREE.DataTexture, ): void { - const { encoding, width, height, is_bigendian } = image; + const { encoding, width, height } = image; + const is_bigendian = "is_bigendian" in image ? image.is_bigendian : false; const rawData = image.data as Uint8Array; switch (encoding) { case "yuv422": @@ -602,6 +655,9 @@ function rawImageToDataTexture( } } +function normalizeImageData(data: Int8Array): Int8Array; +function normalizeImageData(data: PartialMessage | undefined): Uint8Array; +function normalizeImageData(data: unknown): Int8Array | Uint8Array; function normalizeImageData(data: unknown): Int8Array | Uint8Array { if (data == undefined) { return new Uint8Array(0); @@ -612,7 +668,7 @@ function normalizeImageData(data: unknown): Int8Array | Uint8Array { } } -function normalizeImage(message: PartialMessage): Image { +function normalizeRosImage(message: PartialMessage): RosImage { return { header: normalizeHeader(message.header), height: message.height ?? 0, @@ -624,10 +680,33 @@ function normalizeImage(message: PartialMessage): Image { }; } -function normalizeCompressedImage(message: PartialMessage): CompressedImage { +function normalizeRosCompressedImage( + message: PartialMessage, +): RosCompressedImage { return { header: normalizeHeader(message.header), format: message.format ?? "", data: normalizeByteArray(message.data), }; } + +function normalizeRawImage(message: PartialMessage): RawImage { + return { + timestamp: normalizeTime(message.timestamp), + frame_id: message.frame_id ?? "", + height: message.height ?? 0, + width: message.width ?? 0, + encoding: message.encoding ?? "", + step: message.step ?? 0, + data: normalizeImageData(message.data), + }; +} + +function normalizeCompressedImage(message: PartialMessage): CompressedImage { + return { + timestamp: normalizeTime(message.timestamp), + frame_id: message.frame_id ?? "", + format: message.format ?? "", + data: normalizeByteArray(message.data), + }; +} diff --git a/packages/studio-base/src/panels/ThreeDeeRender/stories/ImageRender.stories.tsx b/packages/studio-base/src/panels/ThreeDeeRender/stories/ImageRender.stories.tsx index 9fd9dd88c1..24b21bb65e 100644 --- a/packages/studio-base/src/panels/ThreeDeeRender/stories/ImageRender.stories.tsx +++ b/packages/studio-base/src/panels/ThreeDeeRender/stories/ImageRender.stories.tsx @@ -2,11 +2,12 @@ // License, v2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/ +import { CompressedImage, FrameTransform, RawImage } from "@foxglove/schemas/schemas/typescript"; import { MessageEvent, Topic } from "@foxglove/studio"; import PanelSetup from "@foxglove/studio-base/stories/PanelSetup"; import ThreeDeeRender from "../index"; -import { CameraInfo, CompressedImage, Image, TransformStamped } from "../ros"; +import { CameraInfo, CompressedImage as RosCompressedImage, Image, TransformStamped } from "../ros"; import { BASE_LINK_FRAME_ID, FIXED_FRAME_ID, @@ -102,7 +103,7 @@ export function ImageRender(): JSX.Element { sizeInBytes: 0, }; - const cam1Png: MessageEvent> = { + const cam1Png: MessageEvent> = { topic: "/cam1/png", receiveTime: { sec: 10, nsec: 0 }, message: { @@ -204,3 +205,185 @@ export function ImageRender(): JSX.Element { ); } + +FoxgloveImageRender.parameters = { colorScheme: "light" }; +export function FoxgloveImageRender(): JSX.Element { + const topics: Topic[] = [ + { name: "/tf", datatype: "foxglove.FrameTransform" }, + { name: "/cam1/info", datatype: "foxglove.CameraCalibration" }, + { name: "/cam2/info", datatype: "foxglove.CameraCalibration" }, + { name: "/cam1/png", datatype: "foxglove.CompressedImage" }, + { name: "/cam2/raw", datatype: "foxglove.RawImage" }, + ]; + + const tf1: MessageEvent = { + topic: "/tf", + receiveTime: { sec: 10, nsec: 0 }, + message: { + timestamp: { sec: 0, nsec: 0 }, + parent_frame_id: FIXED_FRAME_ID, + child_frame_id: BASE_LINK_FRAME_ID, + translation: { x: 1e7, y: 0, z: 0 }, + rotation: QUAT_IDENTITY, + }, + sizeInBytes: 0, + }; + const tf2: MessageEvent = { + topic: "/tf", + receiveTime: { sec: 10, nsec: 0 }, + message: { + timestamp: { sec: 0, nsec: 0 }, + parent_frame_id: BASE_LINK_FRAME_ID, + child_frame_id: SENSOR_FRAME_ID, + translation: { x: 0, y: 0, z: 1 }, + rotation: { x: 0.383, y: 0, z: 0, w: 0.924 }, + }, + sizeInBytes: 0, + }; + + const cam1: MessageEvent> = { + topic: "/cam1/info", + receiveTime: { sec: 10, nsec: 0 }, + message: { + header: { seq: 0, stamp: { sec: 0, nsec: 0 }, frame_id: SENSOR_FRAME_ID }, + height: 480, + width: 640, + distortion_model: "rational_polynomial", + D: [0.452407, 0.273748, -0.00011, 0.000152, 0.027904, 0.817958, 0.358389, 0.108657], + K: [ + 381.22076416015625, 0, 318.88323974609375, 0, 381.22076416015625, 233.90321350097656, 0, 0, + 1, + ], + R: [1, 0, 0, 1, 0, 0, 1, 0, 0], + P: [ + 381.22076416015625, 0, 318.88323974609375, 0.015031411312520504, 0, 381.22076416015625, + 233.90321350097656, -0.00011014656047336757, 0, 0, 1, 0.000024338871298823506, + ], + }, + sizeInBytes: 0, + }; + + const cam2: MessageEvent> = { + topic: "/cam2/info", + receiveTime: { sec: 10, nsec: 0 }, + message: { + header: { seq: 0, stamp: { sec: 0, nsec: 0 }, frame_id: SENSOR_FRAME_ID }, + height: 900, + width: 1600, + distortion_model: "", + D: [], + K: [ + 1266.417203046554, 0, 816.2670197447984, 0, 1266.417203046554, 491.50706579294757, 0, 0, 1, + ], + R: [1, 0, 0, 1, 0, 0, 1, 0, 0], + P: [ + 1266.417203046554, 0, 816.2670197447984, 0, 0, 1266.417203046554, 491.50706579294757, 0, 0, + 0, 1, 0, + ], + }, + sizeInBytes: 0, + }; + + const cam1Png: MessageEvent> = { + topic: "/cam1/png", + receiveTime: { sec: 10, nsec: 0 }, + message: { + timestamp: { sec: 0, nsec: 0 }, + frame_id: SENSOR_FRAME_ID, + format: "png", + data: PNG_TEST_IMAGE, + }, + sizeInBytes: 0, + }; + + // Create a Uint8Array 8x8 RGBA image + const SIZE = 8; + const rgba8 = new Uint8Array(SIZE * SIZE * 4); + for (let y = 0; y < SIZE; y++) { + for (let x = 0; x < SIZE; x++) { + const i = (y * SIZE + x) * 4; + rgba8[i + 0] = Math.trunc((x / (SIZE - 1)) * 255); + rgba8[i + 1] = Math.trunc((y / (SIZE - 1)) * 255); + rgba8[i + 2] = 0; + rgba8[i + 3] = 255; + } + } + + const cam2Raw: MessageEvent> = { + topic: "/cam2/raw", + receiveTime: { sec: 10, nsec: 0 }, + message: { + timestamp: { sec: 0, nsec: 0 }, + frame_id: SENSOR_FRAME_ID, + height: SIZE, + width: SIZE, + encoding: "rgba8", + step: SIZE * 4, + data: rgba8, + }, + sizeInBytes: 0, + }; + + const fixture = useDelayedFixture({ + topics, + frame: { + "/tf": [tf1, tf2], + "/cam1/info": [cam1], + "/cam2/info": [cam2], + "/cam1/png": [cam1Png], + "/cam2/raw": [cam2Raw], + }, + capabilities: [], + activeData: { + currentTime: { sec: 0, nsec: 0 }, + }, + }); + + return ( + + + + ); +}