Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,21 @@ A page will automatically open in your browser and you are ready to go.
</table>
</div>

## Camera rotation

If a camera is mounted upside down or sideways, select its **Rotation** in the camera setup on the Calibration page.
Choose 0°, 90°, 180°, or 270° clockwise. For an upside-down wrist camera, choose 180°.

LeLab saves the rotation for each camera. Setup and teleoperation previews, recorded frames, and inference use the selected rotation.
Profiles without a rotation setting use 0°.

The resolution setting describes the camera's capture resolution. A 90° or 270° rotation swaps the output dimensions.
For example, a 640 × 480 capture becomes a 480 × 640 observation. The preview shows the complete rotated frame.

Before inference, select the same rotation used for the training dataset. Check it again if you change the camera binding.
Checkpoint image dimensions do not establish image orientation. For example, 0° and 180° have the same dimensions.
The setting applies to new observations; it does not change existing recordings or checkpoints.

## Resources

- **[LeRobot](https://github.com/huggingface/lerobot):** the underlying library — go here for everything beyond the UI.
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/components/control/BrowserCameraPreview.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot, Root } from "react-dom/client";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import BrowserCameraPreview from "./BrowserCameraPreview";
import CameraRotationSelect from "./CameraRotationSelect";
vi.mock("@/hooks/useCameraStream", () => ({ useCameraStream: () => ({ videoRef: React.createRef<HTMLVideoElement>(), hasError: false }) }));
let root: Root;
let container: HTMLDivElement;
beforeEach(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); container = document.createElement("div"); root = createRoot(container); });
afterEach(() => act(() => root.unmount()));
test("the Rotation control exposes four clockwise values and emits a number", () => {
const change = vi.fn();
act(() => root.render(<CameraRotationSelect rotation={180} onChange={change} />));
const select = container.querySelector("select")!;
expect(container.querySelector("label")!.htmlFor).toBe(select.id);
expect(select.value).toBe("180");
expect(Array.from(select.options).map((option) => option.text)).toEqual(["0° clockwise", "90° clockwise", "180° clockwise", "270° clockwise"]);
act(() => { select.value = "90"; select.dispatchEvent(new Event("change", { bubbles: true })); });
expect(change).toHaveBeenCalledWith(90);
});
test("a quarter turn fits the whole non-square frame and uses actual stream dimensions", () => {
act(() => root.render(<BrowserCameraPreview deviceId="wrist" rotation={90} width={640} height={480} />));
const video = container.querySelector("video")!, wrapper = video.parentElement!;
expect(wrapper.style.aspectRatio).toBe("480 / 640");
expect(video.style.objectFit).toBe("contain");
expect(video.style.maxWidth).toBe("none");
expect(video.style.transform).toBe("translate(-50%, -50%) rotate(90deg)");
expect(parseFloat(video.style.width)).toBeCloseTo(640 / 480 * 100);
expect(parseFloat(video.style.height)).toBeCloseTo(480 / 640 * 100);
Object.defineProperty(video, "videoWidth", { value: 1280 });
Object.defineProperty(video, "videoHeight", { value: 720 });
act(() => video.dispatchEvent(new Event("loadedmetadata")));
expect(wrapper.style.aspectRatio).toBe("720 / 1280");
expect(parseFloat(video.style.width)).toBeCloseTo(1280 / 720 * 100);
expect(parseFloat(video.style.height)).toBeCloseTo(720 / 1280 * 100);
});
test("device reassignment does not reuse the old device's frame geometry", () => {
act(() => root.render(<BrowserCameraPreview deviceId="A" rotation={270} />));
const video = container.querySelector("video")!;
Object.defineProperty(video, "videoWidth", { value: 1920 });
Object.defineProperty(video, "videoHeight", { value: 1080 });
act(() => video.dispatchEvent(new Event("loadedmetadata")));
act(() => root.render(<BrowserCameraPreview deviceId="B" rotation={180} width={640} height={480} />));
expect(video.parentElement!.style.aspectRatio).toBe("640 / 480");
expect(video.style.width).toBe("100%");
expect(video.style.transform).toContain("rotate(180deg)");
});
63 changes: 63 additions & 0 deletions frontend/src/components/control/BrowserCameraPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React, { useState } from "react";
import { VideoOff } from "lucide-react";
import { useCameraStream } from "@/hooks/useCameraStream";
import { CameraRotation, cameraPreviewGeometry, cameraRotationError } from "@/lib/cameraConfig";

interface Props {
deviceId: string;
paused?: boolean;
rotation?: CameraRotation;
width?: number;
height?: number;
className?: string;
fallback?: string;
}

/** Browser streams contain raw frames, so only browser previews use CSS rotation. */
export default function BrowserCameraPreview({
deviceId,
paused = false,
rotation = 0,
width = 640,
height = 480,
className = "",
fallback,
}: Props) {
const rotationError = cameraRotationError({ rotation });
const { videoRef, hasError } = useCameraStream(deviceId, paused || !!rotationError);
const [metadata, setMetadata] = useState<{ deviceId: string; width: number; height: number } | null>(null);
const dimensions = metadata?.deviceId === deviceId ? metadata : { width, height };
const geometry = rotationError
? { aspectRatio: `${width} / ${height}`, videoStyle: {} }
: cameraPreviewGeometry(dimensions.width, dimensions.height, rotation);
const showVideo = !rotationError && !paused && deviceId && !hasError;
return (
<div
className={`relative overflow-hidden bg-gray-800 ${className}`}
style={{ aspectRatio: geometry.aspectRatio }}
>
{showVideo ? (
<video
ref={videoRef}
autoPlay
muted
playsInline
style={geometry.videoStyle}
onLoadedMetadata={(event) => {
const video = event.currentTarget;
if (video.videoWidth > 0 && video.videoHeight > 0) {
setMetadata({ deviceId, width: video.videoWidth, height: video.videoHeight });
}
}}
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center">
<VideoOff className="w-5 h-5 text-gray-500 mb-1" />
<span className="text-xs text-gray-500">
{rotationError ?? fallback ?? (paused ? "Preview paused" : deviceId ? "Preview failed" : "No camera selected")}
</span>
</div>
)}
</div>
);
}
50 changes: 13 additions & 37 deletions frontend/src/components/control/CameraFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,22 @@
import React from "react";
import { VideoOff } from "lucide-react";
import { useCameraStream } from "@/hooks/useCameraStream";
import BrowserCameraPreview from "./BrowserCameraPreview";
import { CameraRotation } from "@/lib/cameraConfig";

interface CameraFeedProps {
/** Browser deviceId to stream. Empty string renders the "no camera" state. */
deviceId: string;
/** Optional caption shown under the feed. */
label?: string;
rotation?: CameraRotation;
width?: number;
height?: number;
}

/** Live browser-camera feed bound to a deviceId via getUserMedia. */
const CameraFeed: React.FC<CameraFeedProps> = ({ deviceId, label }) => {
const { videoRef, hasError } = useCameraStream(deviceId, false);
const showVideo = deviceId && !hasError;

return (
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
<div className="aspect-[4/3] bg-gray-800 relative">
{showVideo ? (
<video
ref={videoRef}
autoPlay
muted
playsInline
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center">
<VideoOff className="w-8 h-8 text-gray-500 mb-2" />
<span className="text-gray-500 text-sm">
{deviceId ? "Preview failed" : "No camera selected"}
</span>
</div>
)}
</div>
{label && (
<div className="p-2 text-sm text-gray-300 truncate border-t border-gray-800">
{label}
</div>
)}
</div>
);
};
const CameraFeed: React.FC<CameraFeedProps> = ({ deviceId, label, rotation, width, height }) => (
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
<BrowserCameraPreview deviceId={deviceId} rotation={rotation} width={width} height={height} />
{label && (
<div className="p-2 text-sm text-gray-300 truncate border-t border-gray-800">{label}</div>
)}
</div>
);

export default CameraFeed;
30 changes: 30 additions & 0 deletions frontend/src/components/control/CameraRotationSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React, { useId } from "react";
import { Label } from "@/components/ui/label";
import { CAMERA_ROTATIONS, CameraRotation, cameraRotation } from "@/lib/cameraConfig";

interface Props {
rotation: CameraRotation | null;
onChange: (rotation: CameraRotation) => void;
disabled?: boolean;
}

export default function CameraRotationSelect({ rotation, onChange, disabled }: Props) {
const id = useId();
return (
<div className="flex items-center gap-2 text-xs">
<Label htmlFor={id} className="text-gray-400">Rotation</Label>
<select
id={id}
value={rotation ?? ""}
disabled={disabled}
onChange={(event) => onChange(cameraRotation(Number(event.target.value)))}
className="bg-gray-800 border border-gray-700 rounded text-white text-xs h-7 px-2"
>
{rotation === null && <option value="" disabled>Choose rotation</option>}
{CAMERA_ROTATIONS.map((angle) => (
<option key={angle} value={angle}>{angle}° clockwise</option>
))}
</select>
</div>
);
}
27 changes: 27 additions & 0 deletions frontend/src/components/control/RecordingCameraFeed.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, test } from "vitest";
import RecordingCameraFeed from "./RecordingCameraFeed";
import { recordingCameraWindows } from "@/lib/cameraConfig";

test("recording feeds use each camera's output ratio without rotating corrected backend frames", () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const windows = recordingCameraWindows(["front", "wrist"], {
front: { type: "opencv", width: 640, height: 480, rotation: 0 },
wrist: { type: "opencv", width: 640, height: 480, rotation: 90 },
}, { w: 1000, h: 600 });
const container = document.createElement("div"), root = createRoot(container);
act(() => root.render(<>{Object.entries(windows).map(([name, dimensions]) => (
<RecordingCameraFeed key={name} name={name} baseUrl="http://test" live {...dimensions} />
))}</>));
const images = Array.from(container.querySelectorAll("img"));
expect(images).toHaveLength(2);
expect(parseFloat(images[0].parentElement!.style.width) / parseFloat(images[0].parentElement!.style.height)).toBeCloseTo(4 / 3, 2);
expect(parseFloat(images[1].parentElement!.style.width) / parseFloat(images[1].parentElement!.style.height)).toBeCloseTo(3 / 4, 2);
for (const image of images) {
expect(image.className).toContain("object-contain");
expect(image.style.transform).toBe("");
}
act(() => root.unmount());
});
75 changes: 75 additions & 0 deletions frontend/src/components/control/RecordingCameraFeed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React, { useState, useEffect, useRef, useCallback } from "react";

interface CameraFeedProps {
baseUrl: string;
name: string;
// False during the preparing phase: show an empty slot until the camera is
// connected and streaming. True once recording/resetting, when frames flow.
live: boolean;
// Pixel size computed by the parent to fit the available space. The box is
// sized to the camera's aspect ratio, so the video fills it without letterbox.
width: number;
height: number;
}

// Renders one recording camera's window at an explicit size. During preparing it
// shows an empty placeholder; once live it plays the backend MJPEG stream. The
// browser renders a `multipart/x-mixed-replace` response natively in an <img>,
// so we just point it at /camera-feed/{name}. If the stream errors before frames
// flow (camera still warming up), retry with a cache-busting key after a delay.
const RecordingCameraFeed: React.FC<CameraFeedProps> = ({
baseUrl,
name,
live,
width,
height,
}) => {
const [reloadKey, setReloadKey] = useState(0);
const [hasError, setHasError] = useState(false);
const retryRef = useRef<number | null>(null);

const src = `${baseUrl}/camera-feed/${encodeURIComponent(name)}?k=${reloadKey}`;

useEffect(() => {
return () => {
if (retryRef.current) window.clearTimeout(retryRef.current);
};
}, []);

const handleError = useCallback(() => {
setHasError(true);
if (retryRef.current) window.clearTimeout(retryRef.current);
retryRef.current = window.setTimeout(() => {
setHasError(false);
setReloadKey((k) => k + 1);
}, 1500);
}, []);

// 0 before the first measurement; skip rendering a zero-size box.
if (width <= 0 || height <= 0) return null;

return (
<div
style={{ width, height }}
className="relative bg-gray-900 rounded-lg border border-gray-700 overflow-hidden flex items-center justify-center"
>
{!live ? (
<span className="text-gray-500 text-sm">Getting ready…</span>
) : hasError ? (
<span className="text-gray-500 text-sm">Connecting feed…</span>
) : (
<img
src={src}
alt={`${name} live feed`}
onError={handleError}
className="w-full h-full object-contain"
/>
)}
<span className="absolute bottom-2 left-2 px-2 py-0.5 rounded bg-black/60 text-sm text-gray-200">
{name}
</span>
</div>
);
};

export default RecordingCameraFeed;
6 changes: 6 additions & 0 deletions frontend/src/components/control/TeleopCameraPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ const TeleopCameraPanel: React.FC = () => {
key: c.id,
name: c.name,
deviceId: c.device_id,
rotation: c.rotation,
width: c.width,
height: c.height,
}));

return (
Expand Down Expand Up @@ -72,6 +75,9 @@ const TeleopCameraPanel: React.FC = () => {
key={`${feed.key}:${reloadKey}`}
deviceId={feed.deviceId}
label={feed.name}
rotation={feed.rotation}
width={feed.width}
height={feed.height}
/>
))}
</div>
Expand Down
Loading
Loading