From d03d3c6702e77ebd1d82dca32eb3ee69dd670006 Mon Sep 17 00:00:00 2001 From: Ravindu Somawansa Date: Thu, 29 Oct 2026 16:31:31 +0100 Subject: [PATCH] feat(cameras): live alignment score and lighting match against the reference frame --- .../recording/CameraConfiguration.test.tsx | 18 ++ .../recording/CameraConfiguration.tsx | 11 +- .../recording/CameraReferenceControls.tsx | 28 ++++ frontend/src/hooks/useReferenceScores.ts | 75 +++++++++ frontend/src/lib/cameraReferenceScore.test.ts | 52 ++++++ frontend/src/lib/cameraReferenceScore.ts | 156 ++++++++++++++++++ 6 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 frontend/src/hooks/useReferenceScores.ts create mode 100644 frontend/src/lib/cameraReferenceScore.test.ts create mode 100644 frontend/src/lib/cameraReferenceScore.ts diff --git a/frontend/src/components/recording/CameraConfiguration.test.tsx b/frontend/src/components/recording/CameraConfiguration.test.tsx index 571552e9c..60b35e99d 100644 --- a/frontend/src/components/recording/CameraConfiguration.test.tsx +++ b/frontend/src/components/recording/CameraConfiguration.test.tsx @@ -8,6 +8,8 @@ vi.mock("@/hooks/useAvailableCameras", () => ({ useAvailableCameras: () => ({ ca vi.mock("@/hooks/use-toast", () => ({ useToast: () => ({ toast: vi.fn() }) })); const reference = { url: null as string | null, error: null, save: vi.fn(), clear: vi.fn() }; vi.mock("@/hooks/useCameraReference", () => ({ useCameraReference: () => reference })); +const scores = { alignment: null as number | null, lighting: null as { match: number; brightnessDiff: number; colorDiff: number } | null }; +vi.mock("@/hooks/useReferenceScores", () => ({ useReferenceScores: () => scores })); vi.mock("@/hooks/useCameraStream", () => ({ useCameraStream: () => ({ videoRef: React.createRef(), hasError: false }) })); let root: Root; let container: HTMLDivElement; @@ -59,3 +61,19 @@ test("reference controls appear only for a named robot in read-only mode", () => expect(slider).not.toBeNull(); reference.url = null; }); + +test("with a reference, the live scores show and the preview gets a halo by alignment", () => { + reference.url = "blob:ref"; + scores.alignment = 95; + scores.lighting = { match: 72, brightnessDiff: 7.04, colorDiff: 0.3 }; + act(() => root.render()); + expect(container.textContent).toContain("Alignment 95% · Lighting 72% (brightness 7.0%, color 0.3%)"); + const preview = () => container.querySelector("video")!.parentElement!.parentElement!; + expect(preview().className).toContain("border-green-500"); + scores.alignment = 60; + act(() => root.render()); + expect(preview().className).toContain("border-orange-500"); + reference.url = null; + scores.alignment = null; + scores.lighting = null; +}); diff --git a/frontend/src/components/recording/CameraConfiguration.tsx b/frontend/src/components/recording/CameraConfiguration.tsx index 60c6b6907..45f7cf425 100644 --- a/frontend/src/components/recording/CameraConfiguration.tsx +++ b/frontend/src/components/recording/CameraConfiguration.tsx @@ -19,9 +19,11 @@ import { import { useToast } from "@/hooks/use-toast"; import { useAvailableCameras } from "@/hooks/useAvailableCameras"; import { useCameraReference } from "@/hooks/useCameraReference"; +import { useReferenceScores } from "@/hooks/useReferenceScores"; import BrowserCameraPreview from "@/components/control/BrowserCameraPreview"; import CameraRotationSelect from "@/components/control/CameraRotationSelect"; import CameraReferenceControls from "@/components/recording/CameraReferenceControls"; +import { ALIGNED_SCORE } from "@/lib/cameraReferenceScore"; import { CameraConfig, cameraRotation, @@ -361,9 +363,15 @@ const CameraPreview: React.FC = ({ const rotationError = cameraRotationError(camera); const rotation = rotationError ? null : cameraRotation(camera.rotation); const output = rotation === null ? null : rotatedDimensions(camera.width, camera.height, rotation); + const scores = useReferenceScores(previewRef, reference.url, !paused && !rotationError); + // Halo around the preview: green once the framing lines up with the reference, orange while it's off. + const halo = + scores.alignment === null + ? "" + : `border-2 ${scores.alignment >= ALIGNED_SCORE ? "border-green-500" : "border-orange-500"}`; return (
-
+
= ({ onClear={() => void reference.clear()} disabled={paused || !!rotationError} error={reference.error} + scores={scores} /> )} {rotationError &&

{rotationError} Select a valid rotation.

} diff --git a/frontend/src/components/recording/CameraReferenceControls.tsx b/frontend/src/components/recording/CameraReferenceControls.tsx index c23e97acc..be959e09e 100644 --- a/frontend/src/components/recording/CameraReferenceControls.tsx +++ b/frontend/src/components/recording/CameraReferenceControls.tsx @@ -1,5 +1,7 @@ import React from "react"; import { Button } from "@/components/ui/button"; +import { ALIGNED_SCORE, LIGHTING_FAIR, LIGHTING_GOOD } from "@/lib/cameraReferenceScore"; +import type { ReferenceScores } from "@/hooks/useReferenceScores"; interface Props { hasReference: boolean; @@ -9,8 +11,32 @@ interface Props { onClear: () => void; disabled: boolean; error: string | null; + scores?: ReferenceScores; } +const lightingTone = (match: number) => + match >= LIGHTING_GOOD ? "text-green-400" : match >= LIGHTING_FAIR ? "text-amber-400" : "text-red-400"; + +/** Live alignment and lighting match against the reference, with the lighting breakdown as text. */ +const ScoreLine: React.FC<{ scores: ReferenceScores }> = ({ scores: { alignment, lighting } }) => ( +

+ Alignment{" "} + {alignment === null ? ( + – + ) : ( + = ALIGNED_SCORE ? "text-green-400" : "text-orange-400"}> + {Math.round(alignment)}% + + )} + {lighting && ( + <> + {" · "}Lighting {Math.round(lighting.match)}% + {` (brightness ${lighting.brightnessDiff.toFixed(1)}%, color ${lighting.colorDiff.toFixed(1)}%)`} + + )} +

+); + /** Save the current frame as the reference, then slide the ghost overlay over the live preview. */ const CameraReferenceControls: React.FC = ({ hasReference, @@ -20,6 +46,7 @@ const CameraReferenceControls: React.FC = ({ onClear, disabled, error, + scores, }) => (
@@ -60,6 +87,7 @@ const CameraReferenceControls: React.FC = ({ /> )} + {hasReference && scores && } {error &&

{error}

}
); diff --git a/frontend/src/hooks/useReferenceScores.ts b/frontend/src/hooks/useReferenceScores.ts new file mode 100644 index 000000000..7b37e1b7b --- /dev/null +++ b/frontend/src/hooks/useReferenceScores.ts @@ -0,0 +1,75 @@ +import { RefObject, useEffect, useState } from "react"; +import { + LightingMatch, + Pixels, + alignmentInput, + alignmentScore, + lightingMatch, + lightingSignature, +} from "@/lib/cameraReferenceScore"; + +const POLL_MS = 500; + +export interface ReferenceScores { + /** 0-100, or null while there is no frame or the sizes differ. */ + alignment: number | null; + lighting: LightingMatch | null; +} + +const EMPTY: ReferenceScores = { alignment: null, lighting: null }; + +function pixelsOf(source: CanvasImageSource, width: number, height: number): Pixels | null { + if (!width || !height) return null; + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + if (!ctx) return null; + ctx.drawImage(source, 0, 0); + return ctx.getImageData(0, 0, width, height); +} + +/** + * Alignment and lighting scores of the `