From d28da6692ca4eda20044cd4e8f5323832205d1f3 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Fri, 23 Oct 2026 15:21:32 -0400 Subject: [PATCH 1/3] Camera section: exposure verdict first, one Check exposure button, settings labeled per camera (#172) The engine judges a saved exposure (good or at the light limit) and sends the range it judged by, so the Editor quotes the engine's levels instead of keeping its own. A failed check leads with its verdict ("Too dark", "Clipping"). The button is Check exposure for every camera; the result says whether it adjusted a webcam or only checked a still camera. With nothing saved, the readout says the scan checks it first. The section is split into Before scanning and Scan settings for , each setting with a visible label and a one-line hint. Assisted-by: Claude Code:claude-opus-5-5 --- CONTEXT.md | 2 +- README.md | 12 ++-- engine/calibrate.py | 18 +++++- engine/hub.py | 2 + engine/messages.py | 9 +++ engine/scan_camera.py | 10 ++-- frontend/index.html | 59 +++++++++++-------- .../src/editor/cameraPanel.browser.test.ts | 45 ++++++++++---- frontend/src/editor/cameraPanel.ts | 30 +++++----- frontend/src/editor/statusView.test.ts | 32 ++++++---- frontend/src/editor/statusView.ts | 18 +++--- .../src/shared/fixtures/engine-messages.json | 1 + frontend/src/shared/messages.ts | 9 +++ tests/test_calibrate.py | 18 ++++++ tests/test_still_scan.py | 3 + 15 files changed, 185 insertions(+), 83 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 2d18ed4..efb6b8f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -31,7 +31,7 @@ A picture of the space as the projector sees it, made by a scan; surfaces are fo _Avoid_: Scene (for the picture), photo, camera image **Calibration**: -Setting the camera's exposure and gain so the projected white is bright but not clipped, before a scan. +Setting the camera's exposure and gain so the projected white is bright but not clipped, before a scan. In the Editor it's **Check exposure** for every camera: it calibrates a webcam, and only checks a still camera, whose exposure is set on it. _Avoid_: Calibration for alignment (say realignment) or for sound timing (say sync measurement) **Coverage**: diff --git a/README.md b/README.md index 74d9c8b..bab6fdc 100644 --- a/README.md +++ b/README.md @@ -161,17 +161,19 @@ The **Camera** dropdown in the Hardware panel lists every camera, labeled `(USB) **Show preview**, in the **Camera** section, is off by default. While it's on, the editor polls a camera frame twice a second; turning it off releases the webcam. A still camera takes a photo per preview (a few seconds each) and reuses it for 5 seconds. Starting a scan turns the preview off, because the scan needs the camera to itself. Use the preview to check that the camera sees the whole projected area. -### 3. Calibrate exposure and check framing +### 3. Check exposure and framing -**With a webcam**, click **Calibrate exposure**. The projector shows full white, the camera's auto controls are locked, and the engine searches for the longest exposure (up to 100 ms) at which the white frame is bright but not clipped. Long exposures brighten the image without the noise that gain adds, and scans don't need speed. Only if the longest exposure is still too dark does it raise gain, step by step. +The Camera section has two groups: **Before scanning** (Show preview, Check exposure, Check framing, each with its last result) and **Scan settings for** the selected camera (Aperture, HDR, Hole fill, Skip an area), which are saved per camera and used by the next scan. -The result shows as "Exposure N, gain G (white frame peak P)". If it reads **"camera at its light limit"**, the camera is at maximum exposure and maximum gain: the projection is very faint for this camera, so expect poor decoding on dark or distant surfaces. +**With a webcam**, click **Check exposure**. The app adjusts the camera: the projector shows full white, the camera's auto controls are locked, and the engine searches for the longest exposure (up to 100 ms) at which the white frame is bright but not clipped. Long exposures brighten the image without the noise that gain adds, and scans don't need speed. Only if the longest exposure is still too dark does it raise gain, step by step. -**With a still camera**, the same button reads **Check exposure**. The app doesn't set the exposure (see [Still camera](#still-camera-sony-a6600)); it photographs the projected white and tells you what to change on the camera: +The Exposure line leads with a verdict, then the white level against what the engine aims for: "Good (white at 181 of 255; aiming near 180, at most 249): exposure 300, gain 0, set by the app". **At the camera's light limit** means maximum exposure and maximum gain and still dim: brighten the projector, darken the room or move the camera closer, or expect poor decoding on dark or distant surfaces. Before any check it reads "Not checked yet": that isn't blocking anything, since the scan checks first. + +**With a still camera**, the same button only checks. The app doesn't set the exposure (see [Still camera](#still-camera-sony-a6600)); it photographs the projected white and tells you what to change on the camera: | Result | Do this on the camera | |--------|-----------------------| -| Exposure is good | Nothing. | +| Good (white at 149 of 255; a still camera needs 120 to 249) | Nothing. | | Too dark | A slower shutter or a higher ISO. | | Clipping | A faster shutter or a lower ISO. | | ISO is Auto | Set a number, so every pattern is photographed the same way. | diff --git a/engine/calibrate.py b/engine/calibrate.py index be6519c..c9ac263 100644 --- a/engine/calibrate.py +++ b/engine/calibrate.py @@ -16,12 +16,28 @@ STALE_FRAMES = 2 # frames captured before the new exposure took effect MAX_GAIN = 15 # AC410 range TARGET_LEVEL = 180 # below this at the longest exposure, add gain (dark or distant surfaces) +DARK_LEVEL = 120 # a still camera's white frame below this: too dark to separate lit from unlit +STILL_RANGE = f"a still camera needs {DARK_LEVEL} to {CLIP_LEVEL - 1}" # quoted in its check's verdict (#172) class CalibrationError(Exception): pass +def exposure_verdict(calibration: dict | None) -> dict | None: + """A saved exposure's verdict and the range behind it. Only good or light-limited ones are saved: + a dark or clipping white frame fails the check instead.""" + if calibration is None: + return None + still = bool(calibration.get("manual")) + return { + "verdict": "light_limit" if calibration.get("at_light_limit") else "good", + "low": DARK_LEVEL if still else None, + "high": CLIP_LEVEL - 1, + "target": None if still else TARGET_LEVEL, + } + + def calibrate_exposure(uvc: Uvc, read_frame: Callable[[], np.ndarray], longer_exposures: tuple[int, ...] = LONGER_EXPOSURES) -> dict: def brightness_at(exposure: int) -> float: @@ -39,7 +55,7 @@ def brightness_at(exposure: int) -> float: "camera being controlled may be different devices, or the lens is covered." ) if darkest >= CLIP_LEVEL: - raise CalibrationError("White frame clips even at the shortest exposure. Dim the projector or the room.") + raise CalibrationError("Clipping even at the shortest exposure: dim the projector or the room.") if brightest < CLIP_LEVEL: found = _add_gain(uvc, read_frame, brightest, longest) # All the exposure and gain this camera has, and still short of the target: the editor says so. diff --git a/engine/hub.py b/engine/hub.py index e35cd50..ac2f8a9 100644 --- a/engine/hub.py +++ b/engine/hub.py @@ -7,6 +7,7 @@ from fastapi import WebSocket from pydantic import ValidationError +from engine.calibrate import exposure_verdict from engine.cameras import CameraSettings from engine.hardware import HardwareSnapshot from engine.messages import EngineMessage, OutputStats @@ -120,6 +121,7 @@ def _camera_status(self) -> dict: "selected": selected, "calibration": calibration, "at_light_limit": bool(calibration and calibration.get("at_light_limit")), + "exposure": exposure_verdict(calibration), "battery": self.still_battery, "battery_state": _battery_state(self.still_battery), } diff --git a/engine/messages.py b/engine/messages.py index 76f5586..ef69649 100644 --- a/engine/messages.py +++ b/engine/messages.py @@ -227,10 +227,19 @@ class CalibrationOut(_Out): manual: bool | None = None # a still camera: exposure set on the camera, checked not calibrated +class ExposureOut(_Out): + """The saved exposure's verdict, with the engine's range for the editor to quote (#172).""" + verdict: Literal["good", "light_limit"] + low: int | None # a still camera's least white level; a webcam has none, it adds gain + high: int # the most before clipping + target: int | None # what a webcam's calibration aims for + + class CameraStatusOut(_Out): selected: str | None calibration: CalibrationOut | None at_light_limit: bool # longest exposure and most gain, still too dim + exposure: ExposureOut | None battery: int | None # a still camera's last reading in percent; None before one battery_state: Literal["ok", "low", "flat"] | None # flat: Scan is disabled diff --git a/engine/scan_camera.py b/engine/scan_camera.py index f81e70d..4cd8647 100644 --- a/engine/scan_camera.py +++ b/engine/scan_camera.py @@ -21,7 +21,7 @@ import numpy as np -from engine.calibrate import CLIP_LEVEL, LONGER_EXPOSURES, CalibrationError, calibrate_exposure +from engine.calibrate import CLIP_LEVEL, DARK_LEVEL, LONGER_EXPOSURES, STILL_RANGE, CalibrationError, calibrate_exposure from engine.camera_device import CameraSession from engine.camera_lock import Uvc, locked_camera from engine.cameras import CameraSettings, UsbAddress, usb_address @@ -114,9 +114,6 @@ def set(self, name: str, value: str) -> None: pass -DARK_LEVEL = 120 # a still camera's white frame below this: too dark to separate lit from unlit - - class _Still: drop_frames = 0 # each frame is a fresh photo frames_per_pattern = 1 @@ -176,10 +173,11 @@ def calibrate(self) -> dict: raise CalibrationError(f"The camera's ISO is {iso}: set a number on the camera (e.g. 400), " "so every pattern is photographed the same way.") p99 = float(np.percentile(self.read(), 99)) + level = f"(white at {round(p99)} of 255; {STILL_RANGE})" if p99 >= CLIP_LEVEL: - raise CalibrationError("The white frame is clipping: use a faster shutter or a lower ISO on the camera.") + raise CalibrationError(f"Clipping {level}: use a faster shutter or a lower ISO on the camera.") if p99 < DARK_LEVEL: - raise CalibrationError("The white frame is too dark: use a slower shutter or a higher ISO on the camera.") + raise CalibrationError(f"Too dark {level}: use a slower shutter or a higher ISO on the camera.") shutter = _seconds(self._still.driver.get_config("shutterspeed")) or 0.01 return {"exposure": max(1, round(shutter * 10000)), "gain": 0, "p99": p99, "manual": True} diff --git a/frontend/index.html b/frontend/index.html index b4901c0..590fdaf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -107,6 +107,7 @@ #scene-list li input[type="number"] { width: 4.5em; } #scene-list li button { padding: 2px 6px; min-width: 24px; min-height: 24px; } .hint { font-size: 12px; margin: 0 0 8px; } + details.fold h3 { font-size: 12px; font-weight: 600; margin: 12px 0 4px; } #surfaces.drawing { cursor: crosshair; } #surfaces polyline.draft { fill: rgba(251, 191, 36, 0.15); stroke: #fbbf24; stroke-width: 2; stroke-dasharray: 6 4; vector-effect: non-scaling-stroke; pointer-events: none; } #surfaces circle.draft { fill: #fbbf24; pointer-events: none; } @@ -302,47 +303,59 @@

Surface

Off

Camera

+

Before scanning

+

Each shows white on the projector. Check framing measures how much of the camera's view the projection fills (the more, the finer the scan) and outlines it on the preview. Check exposure makes sure the white is neither too dark nor clipping.

- +
Exposure
…
- -
- -
-

-
- +

Scan settings

+

Saved for this camera. Used by the next scan.

+ +
+ +

Several exposures per pattern, keeping the best-lit reading: helps when dark and bright surfaces share the scene. Slower. A still camera brackets by itself: 3 photos, 2 stops apart.

+
+ +

Fills small gaps in the scan. More: fuller surfaces. Less: truer edges.

+
+
+

Draw around a window, a TV or shiny things on the preview, and the scan leaves them out: click corners, then double-click or press Enter. Click a shaded area to stop skipping it.

Test frame

diff --git a/frontend/src/editor/cameraPanel.browser.test.ts b/frontend/src/editor/cameraPanel.browser.test.ts index dc52817..dfbf1e5 100644 --- a/frontend/src/editor/cameraPanel.browser.test.ts +++ b/frontend/src/editor/cameraPanel.browser.test.ts @@ -32,7 +32,7 @@ function status(selected: typeof A6600 | typeof AC410): StatusMessage { type: "status", hardware: { projector: { name: "TV", key: "tv", width: 8, height: 4 }, projector_missing: null, displays: [], cameras: [A6600, AC410], issues: [] }, output_connected: true, output_resolution: { width: 8, height: 4 }, - camera: { selected: selected.unique_id, calibration: null, at_light_limit: false, battery: null, battery_state: null }, + camera: { selected: selected.unique_id, calibration: null, at_light_limit: false, exposure: null, battery: null, battery_state: null }, project: null, can_scan: true, scan_blocker: null, } as StatusMessage; } @@ -78,16 +78,16 @@ describe("the Camera section", () => { revoke.mockRestore(); }); - it("says what calibration found, and lets the camera go when not previewing", async () => { + it("says it adjusted a webcam, and lets the camera go when not previewing", async () => { const { engine, calls } = fakeEngine(); const panel = mountCameraPanel({ engine, notice: () => {} }); panel.update(status(AC410), false); $("calibrate").click(); await vi.waitFor(() => expect($("exposure-note").hidden).toBe(false)); - expect($("exposure-note").textContent).toBe("Calibrated: exposure 1200, white peak 201"); + expect($("exposure-note").textContent).toBe("Adjusted the camera: exposure 1200, gain 3"); expect($("exposure-note").classList.contains("warn")).toBe(false); await vi.waitFor(() => expect(calls).toContain("releaseCamera")); - expect($("calibrate").textContent).toBe("Calibrate exposure"); + expect($("calibrate").textContent).toBe("Check exposure"); }); it("keeps why calibration failed in the section until another camera is chosen (#171)", async () => { @@ -95,7 +95,7 @@ describe("the Camera section", () => { const panel = mountCameraPanel({ engine, notice: () => {} }); panel.update(status(AC410), false); $("calibrate").click(); - await vi.waitFor(() => expect($("exposure-note").textContent).toBe("Calibration failed: Output window is not connected")); + await vi.waitFor(() => expect($("exposure-note").textContent).toBe("Output window is not connected")); expect($("exposure-note").classList.contains("warn")).toBe(true); panel.update(status(AC410), false); expect($("exposure-note").hidden).toBe(false); @@ -178,27 +178,50 @@ describe("the Camera section", () => { expect(calls).toContain('setScanSettings {"hdr":3}'); }); - it("for a still camera, checks the exposure set on it rather than calibrating", () => { - const { engine } = fakeEngine(); + it("keeps one button name for both kinds of camera, and says a still camera was only checked (#172)", async () => { + const { engine } = fakeEngine({ calibrate: async () => json({ exposure: 167, gain: 0, p99: 149, manual: true }) }); const panel = mountCameraPanel({ engine, notice: () => {} }); panel.update(status(A6600), false); expect($("calibrate").textContent).toBe("Check exposure"); expect($("hdr-row").hidden).toBe(false); // HDR: the camera's own bracketing + $("calibrate").click(); + await vi.waitFor(() => expect($("exposure-note").textContent).toBe("Checked the exposure set on the camera; nothing changed on it")); panel.update(status(AC410), false); - expect($("calibrate").textContent).toBe("Calibrate exposure"); + expect($("calibrate").textContent).toBe("Check exposure"); }); it("says what to change on a still camera when its exposure isn't right", async () => { - const detail = "The white frame is too dark: use a slower shutter or a higher ISO on the camera."; + const detail = "Too dark (white at 90 of 255; a still camera needs 120 to 249): use a slower shutter or a higher ISO on the camera."; const { engine } = fakeEngine({ calibrate: async () => json({ detail }, 422) }); const panel = mountCameraPanel({ engine, notice: () => {} }); panel.update(status(A6600), false); $("calibrate").click(); expect($("calibrate").textContent).toBe("Checking…"); - await vi.waitFor(() => expect($("exposure-note").textContent).toBe(`Exposure check failed: ${detail}`)); + await vi.waitFor(() => expect($("exposure-note").textContent).toBe(detail)); // the engine's verdict first (#172) expect($("calibrate").textContent).toBe("Check exposure"); }); + it("names the camera its scan settings belong to, each setting labeled and explained on screen (#172)", () => { + const { engine } = fakeEngine(); + const panel = mountCameraPanel({ engine, notice: () => {} }); + panel.update(status(A6600), false); + expect($("scan-settings-camera").textContent).toBe("Scan settings for Sony Alpha-A6600"); + expect(document.querySelector("[data-fold=camera]")!.textContent).toContain("Before scanning"); + expect(document.querySelector("[data-fold=camera]")!.textContent!.match(/Used by the next scan/g)).toHaveLength(1); + for (const id of ["aperture", "hdr", "hole-fill"]) { + expect($(id).labels?.[0]?.textContent?.trim(), id).toBeTruthy(); // visible, not only aria-label + } + for (const id of ["aperture", "hdr", "hole-fill", "mask-draw"]) { + const hint = ($(id).getAttribute("aria-describedby") ?? "").split(/\s+/) + .map((h) => document.getElementById(h)).find((h) => h?.classList.contains("hint")); + expect(hint?.textContent?.length ?? 0, id).toBeGreaterThan(20); + expect(hint!.closest("[hidden]"), id).toBeNull(); + } + expect([...$("hdr").options].map((o) => o.text)).not.toContain("HDR: off"); // the label says HDR + panel.update(status(AC410), false); + expect($("scan-settings-camera").textContent).toBe("Scan settings for Webcam AC410"); + }); + it("says drawn areas are skipped (#170)", async () => { const twoAreas = [[[0, 0], [0.2, 0], [0.2, 0.2]], [[0.5, 0.5], [0.7, 0.5], [0.7, 0.7]]]; const { engine } = fakeEngine({ scanSettings: async () => json({ hole_fill: 9, hdr: 1, mask: twoAreas, aperture: "8" }) }); @@ -206,7 +229,7 @@ describe("the Camera section", () => { expect($("mask-draw").textContent).toBe("Skip an area"); await panel.loadScanSettings(); expect($("mask-note").textContent).toBe("Skipping 2 areas"); - expect($("mask-draw").title + $("mask-draw").closest(".row")!.getAttribute("title")).toMatch(/skip/i); + expect($("mask-hint").textContent).toMatch(/leaves them out/); // on screen, not only a tooltip (#172) }); it("removes one skipped area when it's clicked, keeping the others", async () => { diff --git a/frontend/src/editor/cameraPanel.ts b/frontend/src/editor/cameraPanel.ts index 846aa98..dd8de66 100644 --- a/frontend/src/editor/cameraPanel.ts +++ b/frontend/src/editor/cameraPanel.ts @@ -174,19 +174,19 @@ export function mountCameraPanel(deps: CameraPanelDeps): CameraPanel { render(); } } - calibrate.addEventListener("click", () => { - const checking = calibrate.textContent === "Check exposure"; // a still camera: its own exposure, checked - void usingCamera(calibrate, checking ? "Checking…" : "Calibrating…", async () => { - const res = await engine.calibrate(); - const body = await res.json(); - const c = body as CalibrateResponse; - // Kept in the section until the next check or another camera (#171), pass or fail. - exposureNote.textContent = !res.ok ? `${checking ? "Exposure check" : "Calibration"} failed: ${body.detail}` - : checking ? `Exposure is good: white peak ${Math.round(c.p99)}` : `Calibrated: exposure ${c.exposure}, white peak ${Math.round(c.p99)}`; - exposureNote.classList.toggle("warn", !res.ok); - exposureNote.hidden = false; - }); - }); + // One name for both kinds of camera (#172): the result says whether it adjusted the camera (a webcam) + // or only checked the exposure set on it (a still camera). The verdict is in the Exposure line. + calibrate.addEventListener("click", () => void usingCamera(calibrate, "Checking…", async () => { + const res = await engine.calibrate(); + const body = await res.json(); + const c = body as CalibrateResponse; + // Kept in the section until the next check or another camera (#171), pass or fail. A failure is + // the engine's own words, verdict first ("Too dark (...): use a slower shutter ..."). + exposureNote.textContent = !res.ok ? body.detail + : c.manual ? "Checked the exposure set on the camera; nothing changed on it" : `Adjusted the camera: exposure ${c.exposure}, gain ${c.gain}`; + exposureNote.classList.toggle("warn", !res.ok); + exposureNote.hidden = false; + })); checkFraming.addEventListener("click", () => void usingCamera(checkFraming, "Checking…", async () => { const res = await engine.checkFraming(); const body = await res.json(); @@ -234,9 +234,7 @@ export function mountCameraPanel(deps: CameraPanelDeps): CameraPanel { const sel = status?.hardware.cameras.find((c) => c.unique_id === status?.camera.selected); const still = sel?.device_type === "still"; apertureRow.hidden = !still; // a webcam's lens has none to set - // A still camera's exposure is set on it (the a6600 takes one exposure change over USB): the - // engine checks it rather than calibrating. Its HDR is its own bracketing (3 photos per press). - if (!calibrate.dataset.busy) calibrate.textContent = still ? "Check exposure" : "Calibrate exposure"; + $("scan-settings-camera").textContent = sel ? `Scan settings for ${sel.name}` : "Scan settings"; // saved per camera } return { diff --git a/frontend/src/editor/statusView.test.ts b/frontend/src/editor/statusView.test.ts index c622a6c..1b466fc 100644 --- a/frontend/src/editor/statusView.test.ts +++ b/frontend/src/editor/statusView.test.ts @@ -6,7 +6,7 @@ const AC410: CameraInfo = { name: "Webcam AC410", unique_id: "0x2110000f1311306" const FACETIME: CameraInfo = { name: "FaceTime HD Camera", unique_id: "3F45E80A", device_type: "builtin" }; function camera(over: Partial = {}): StatusMessage["camera"] { - return { selected: AC410.unique_id, calibration: null, at_light_limit: false, battery: null, battery_state: null, ...over }; + return { selected: AC410.unique_id, calibration: null, at_light_limit: false, exposure: null, battery: null, battery_state: null, ...over }; } function status(over: Partial = {}, issues: StatusMessage["hardware"]["issues"] = []): StatusMessage { @@ -93,14 +93,18 @@ describe("cameraOptions", () => { }); }); +// The verdict and its range come from the engine (#172): the editor keeps no levels of its own. +const WEBCAM_GOOD = { verdict: "good", low: null, high: 249, target: 180 } as const; +const STILL_GOOD = { verdict: "good", low: 120, high: 249, target: null } as const; + describe("calibration summary", () => { - it("shows the saved exposure for the selected camera", () => { - const view = describeStatus(status({ camera: camera({ calibration: { exposure: 124, gain: 0, p99: 248 } }) })); - expect(view.calibration).toBe("Exposure 124, gain 0 (white frame peak 248)"); + it("leads with the verdict, then the white level against the engine's range", () => { + const view = describeStatus(status({ camera: camera({ calibration: { exposure: 124, gain: 0, p99: 248 }, exposure: WEBCAM_GOOD }) })); + expect(view.calibration).toBe("Good (white at 248 of 255; aiming near 180, at most 249): exposure 124, gain 0, set by the app"); }); - it("says when the camera is not calibrated", () => { - expect(describeStatus(status()).calibration).toBe("Not calibrated"); + it("doesn't look like a step blocking the scan when nothing is saved", () => { + expect(describeStatus(status()).calibration).toBe("Not checked yet: the scan checks it first, or check exposure now"); }); }); @@ -114,15 +118,17 @@ describe("project label", () => { describe("calibration at the camera's limit", () => { const calibration = { exposure: 1000, gain: 15, p99: 120 }; - it("says so when the engine says the camera is at its light limit", () => { - const view = describeStatus(status({ camera: camera({ calibration, at_light_limit: true }) })); - expect(view.calibration).toBe("Exposure 1000, gain 15 (white frame peak 120) - camera at its light limit"); + it("says so, and what to change, when the engine says the camera is at its light limit", () => { + const exposure = { ...WEBCAM_GOOD, verdict: "light_limit" } as const; + const view = describeStatus(status({ camera: camera({ calibration, at_light_limit: true, exposure }) })); + expect(view.calibration).toBe("At the camera's light limit (white at 120 of 255; aiming near 180, at most 249): " + + "brighten the projector, darken the room or move the camera closer"); }); it("keeps no limits of its own: the engine decides, per camera (#142)", () => { // 100 ms and gain 15 was the AC410's old limit; a still camera goes to seconds. - const view = describeStatus(status({ camera: camera({ calibration, at_light_limit: false }) })); - expect(view.calibration).toBe("Exposure 1000, gain 15 (white frame peak 120)"); + const view = describeStatus(status({ camera: camera({ calibration, at_light_limit: false, exposure: WEBCAM_GOOD }) })); + expect(view.calibration).toMatch(/^Good /); }); }); @@ -203,8 +209,8 @@ describe("camera battery (still cameras)", () => { describe("a still camera's exposure", () => { it("is the camera's own, checked by the engine", () => { const calibration = { exposure: 167, gain: 0, p99: 201.4, manual: true }; - expect(describeStatus(status({ camera: camera({ calibration }) })).calibration) - .toBe("Set on the camera (white frame peak 201)"); + expect(describeStatus(status({ camera: camera({ calibration, exposure: STILL_GOOD }) })).calibration) + .toBe("Good (white at 201 of 255; a still camera needs 120 to 249): set on the camera, checked by the app"); }); }); diff --git a/frontend/src/editor/statusView.ts b/frontend/src/editor/statusView.ts index e7e82ce..57c15a8 100644 --- a/frontend/src/editor/statusView.ts +++ b/frontend/src/editor/statusView.ts @@ -1,4 +1,4 @@ -import type { Calibration, CameraInfo, HardwareIssue, StatusMessage } from "../shared/messages"; +import type { Calibration, CameraInfo, Exposure, HardwareIssue, StatusMessage } from "../shared/messages"; export interface StatusView { /** Problems that stop the work: full-width banners. */ @@ -83,17 +83,21 @@ export function describeStatus(status: StatusMessage | null): StatusView { : status.scan_blocker ?? "The rig isn't ready to scan", projector: projector ? `${projector.name} (${size(projector)})` : "None", output: status.output_connected && out ? `Output connected (${size(out)})` : "Output not connected", - calibration: describeCalibration(status.camera.calibration, status.camera.at_light_limit), + calibration: describeCalibration(status.camera.calibration, status.camera.exposure), project: status.project?.name ?? "Unsaved", battery: battery === null ? null : `Battery ${battery}%`, }; } -function describeCalibration(c: Calibration | null, atLightLimit: boolean): string { - if (!c) return "Not calibrated"; - if (c.manual) return `Set on the camera (white frame peak ${Math.round(c.p99)})`; - const text = `Exposure ${c.exposure}, gain ${c.gain} (white frame peak ${Math.round(c.p99)})`; - return atLightLimit ? `${text} - camera at its light limit` : text; +// The verdict first, then the white level against the engine's range (#172). +function describeCalibration(c: Calibration | null, e: Exposure | null): string { + if (!c || !e) return "Not checked yet: the scan checks it first, or check exposure now"; + const range = e.low !== null ? `a still camera needs ${e.low} to ${e.high}` : `aiming near ${e.target}, at most ${e.high}`; + const level = `(white at ${Math.round(c.p99)} of 255; ${range})`; + if (e.verdict === "light_limit") { + return `At the camera's light limit ${level}: brighten the projector, darken the room or move the camera closer`; + } + return `Good ${level}: ${c.manual ? "set on the camera, checked by the app" : `exposure ${c.exposure}, gain ${c.gain}, set by the app`}`; } // USB webcams have uniqueIDs like 0x2110000f1311306 (location + vendor + product). diff --git a/frontend/src/shared/fixtures/engine-messages.json b/frontend/src/shared/fixtures/engine-messages.json index b7624fa..fb82165 100644 --- a/frontend/src/shared/fixtures/engine-messages.json +++ b/frontend/src/shared/fixtures/engine-messages.json @@ -259,6 +259,7 @@ "battery": null, "battery_state": null, "calibration": null, + "exposure": null, "selected": "0x2110000f1311306" }, "can_scan": false, diff --git a/frontend/src/shared/messages.ts b/frontend/src/shared/messages.ts index b91e0d6..c51d19f 100644 --- a/frontend/src/shared/messages.ts +++ b/frontend/src/shared/messages.ts @@ -31,6 +31,14 @@ export interface Calibration { manual?: boolean | null; // a still camera: exposure set on the camera, checked not calibrated } +/** The saved exposure's verdict, with the engine's range to quote (#172). */ +export interface Exposure { + verdict: "good" | "light_limit"; + low: number | null; // a still camera's least white level + high: number; // the most before clipping + target: number | null; // what a webcam's calibration aims for +} + export interface StatusMessage { type: "status"; hardware: { @@ -54,6 +62,7 @@ export interface StatusMessage { selected: string | null; calibration: Calibration | null; at_light_limit: boolean; + exposure: Exposure | null; battery: number | null; battery_state: "ok" | "low" | "flat" | null; }; diff --git a/tests/test_calibrate.py b/tests/test_calibrate.py index 651ad92..c1759a5 100644 --- a/tests/test_calibrate.py +++ b/tests/test_calibrate.py @@ -167,3 +167,21 @@ def read(): assert run(0.05)["at_light_limit"] is True # 50 at 100 ms, ~87 with all the gain: too dim assert run(0.12)["at_light_limit"] is False # reaches the target with some gain assert run(1.0)["at_light_limit"] is False # bright: a shorter exposure, no gain + + +def test_a_saved_exposure_gets_a_verdict_and_the_range_it_was_judged_by(): + # The editor leads with the verdict and quotes the engine's range, not a copy of it (#172). + from engine.calibrate import exposure_verdict + + assert exposure_verdict(None) is None + webcam = exposure_verdict({"exposure": 300, "gain": 0, "p99": 181.0}) + assert webcam == {"verdict": "good", "low": None, "high": 249, "target": 180} + assert exposure_verdict({"exposure": 1000, "gain": 15, "p99": 120.0, "at_light_limit": True})["verdict"] == "light_limit" + still = exposure_verdict({"exposure": 167, "gain": 0, "p99": 149.0, "manual": True}) + assert still == {"verdict": "good", "low": 120, "high": 249, "target": None} + + +def test_status_carries_the_exposure_verdict(tmp_path): + hw = FakeHardware(displays=[LAPTOP, PROJECTOR], cameras=RIG_CAMERAS) + with engine(hw, data_dir=tmp_path) as client: + assert client.get("/api/status").json()["camera"]["exposure"] is None # nothing saved yet diff --git a/tests/test_still_scan.py b/tests/test_still_scan.py index ec6d159..fa42627 100644 --- a/tests/test_still_scan.py +++ b/tests/test_still_scan.py @@ -78,6 +78,7 @@ def test_preview_and_calibration_work_with_a_still_camera(rig, tmp_path): cal = client.post("/api/camera/calibrate") assert cal.status_code == 200, cal.text assert client.get("/api/status").json()["camera"]["calibration"]["exposure"] > 0 + assert client.get("/api/status").json()["camera"]["exposure"]["low"] == 120 def test_a_scan_sets_the_cameras_aperture_and_calibrates_for_it(rig, tmp_path): @@ -185,8 +186,10 @@ def test_a_still_cameras_exposure_is_checked_not_changed(rig, tmp_path): def test_the_check_says_what_to_change_on_the_camera(rig, tmp_path): dark = check_exposure(rig, tmp_path, shutterspeed="1/4000") assert dark.status_code == 422 and "slower shutter" in dark.json()["detail"] + assert dark.json()["detail"].startswith("Too dark (white at ") # the verdict first (#172) bright = check_exposure(rig, tmp_path, shutterspeed="1") assert bright.status_code == 422 and "faster shutter" in bright.json()["detail"] + assert bright.json()["detail"].startswith("Clipping (white at ") auto = check_exposure(rig, tmp_path, iso="Auto ISO") assert auto.status_code == 422 and "Auto ISO" in auto.json()["detail"] From d657164203b904f32a8fc6856e509e1550b7ddc4 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Fri, 23 Oct 2026 15:24:27 -0400 Subject: [PATCH 2/3] Check exposure's result leads with the engine's verdict too (#172) Assisted-by: Claude Code:claude-opus-5-5 --- frontend/index.html | 2 +- frontend/src/editor/cameraPanel.browser.test.ts | 13 +++++++++++-- frontend/src/editor/cameraPanel.ts | 8 ++++---- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 590fdaf..c9e095a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -304,7 +304,7 @@

Surface

Camera

Before scanning

-

Each shows white on the projector. Check framing measures how much of the camera's view the projection fills (the more, the finer the scan) and outlines it on the preview. Check exposure makes sure the white is neither too dark nor clipping.

+

Check framing and Check exposure each show white on the projector. Check framing measures how much of the camera's view the projection fills (the more, the finer the scan) and outlines it on the preview. Check exposure makes sure the white is neither too dark nor clipping.

diff --git a/frontend/src/editor/cameraPanel.browser.test.ts b/frontend/src/editor/cameraPanel.browser.test.ts index dfbf1e5..0007a35 100644 --- a/frontend/src/editor/cameraPanel.browser.test.ts +++ b/frontend/src/editor/cameraPanel.browser.test.ts @@ -84,12 +84,21 @@ describe("the Camera section", () => { panel.update(status(AC410), false); $("calibrate").click(); await vi.waitFor(() => expect($("exposure-note").hidden).toBe(false)); - expect($("exposure-note").textContent).toBe("Adjusted the camera: exposure 1200, gain 3"); + expect($("exposure-note").textContent).toBe("Good: adjusted the camera to exposure 1200, gain 3"); expect($("exposure-note").classList.contains("warn")).toBe(false); await vi.waitFor(() => expect(calls).toContain("releaseCamera")); expect($("calibrate").textContent).toBe("Check exposure"); }); + it("leads with the light limit when the camera has no more to give (#172)", async () => { + const { engine } = fakeEngine({ calibrate: async () => json({ exposure: 1000, gain: 15, p99: 120, at_light_limit: true }) }); + const panel = mountCameraPanel({ engine, notice: () => {} }); + panel.update(status(AC410), false); + $("calibrate").click(); + await vi.waitFor(() => expect($("exposure-note").textContent) + .toBe("At the camera's light limit: adjusted the camera to exposure 1000, gain 15")); + }); + it("keeps why calibration failed in the section until another camera is chosen (#171)", async () => { const { engine } = fakeEngine({ calibrate: async () => json({ detail: "Output window is not connected" }, 409) }); const panel = mountCameraPanel({ engine, notice: () => {} }); @@ -185,7 +194,7 @@ describe("the Camera section", () => { expect($("calibrate").textContent).toBe("Check exposure"); expect($("hdr-row").hidden).toBe(false); // HDR: the camera's own bracketing $("calibrate").click(); - await vi.waitFor(() => expect($("exposure-note").textContent).toBe("Checked the exposure set on the camera; nothing changed on it")); + await vi.waitFor(() => expect($("exposure-note").textContent).toBe("Good: checked the exposure set on the camera; nothing changed on it")); panel.update(status(AC410), false); expect($("calibrate").textContent).toBe("Check exposure"); }); diff --git a/frontend/src/editor/cameraPanel.ts b/frontend/src/editor/cameraPanel.ts index dd8de66..928d66e 100644 --- a/frontend/src/editor/cameraPanel.ts +++ b/frontend/src/editor/cameraPanel.ts @@ -174,16 +174,16 @@ export function mountCameraPanel(deps: CameraPanelDeps): CameraPanel { render(); } } - // One name for both kinds of camera (#172): the result says whether it adjusted the camera (a webcam) - // or only checked the exposure set on it (a still camera). The verdict is in the Exposure line. + // One name for both kinds of camera (#172): the result leads with the engine's verdict, then says whether + // it adjusted the camera (a webcam) or only checked the exposure set on it (a still camera). calibrate.addEventListener("click", () => void usingCamera(calibrate, "Checking…", async () => { const res = await engine.calibrate(); const body = await res.json(); const c = body as CalibrateResponse; // Kept in the section until the next check or another camera (#171), pass or fail. A failure is // the engine's own words, verdict first ("Too dark (...): use a slower shutter ..."). - exposureNote.textContent = !res.ok ? body.detail - : c.manual ? "Checked the exposure set on the camera; nothing changed on it" : `Adjusted the camera: exposure ${c.exposure}, gain ${c.gain}`; + const done = c.manual ? "checked the exposure set on the camera; nothing changed on it" : `adjusted the camera to exposure ${c.exposure}, gain ${c.gain}`; + exposureNote.textContent = !res.ok ? body.detail : `${c.at_light_limit ? "At the camera's light limit" : "Good"}: ${done}`; exposureNote.classList.toggle("warn", !res.ok); exposureNote.hidden = false; })); From 4c74c137d8ff7a67edd7854633e575ef6ca880ec Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Fri, 23 Oct 2026 15:31:27 -0400 Subject: [PATCH 3/3] statusView tests: quote ranges the engine doesn't use, so a frontend copy fails (#172) Assisted-by: Claude Code:claude-opus-5-5 --- frontend/src/editor/statusView.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/frontend/src/editor/statusView.test.ts b/frontend/src/editor/statusView.test.ts index 1b466fc..4f3545a 100644 --- a/frontend/src/editor/statusView.test.ts +++ b/frontend/src/editor/statusView.test.ts @@ -93,14 +93,15 @@ describe("cameraOptions", () => { }); }); -// The verdict and its range come from the engine (#172): the editor keeps no levels of its own. -const WEBCAM_GOOD = { verdict: "good", low: null, high: 249, target: 180 } as const; -const STILL_GOOD = { verdict: "good", low: 120, high: 249, target: null } as const; +// The verdict and its range come from the engine (#172): the editor keeps no levels of its own, so these +// are numbers the engine doesn't use. A copy of 120/180/249 in the frontend would fail these tests. +const WEBCAM_GOOD = { verdict: "good", low: null, high: 230, target: 150 } as const; +const STILL_GOOD = { verdict: "good", low: 100, high: 230, target: null } as const; describe("calibration summary", () => { it("leads with the verdict, then the white level against the engine's range", () => { const view = describeStatus(status({ camera: camera({ calibration: { exposure: 124, gain: 0, p99: 248 }, exposure: WEBCAM_GOOD }) })); - expect(view.calibration).toBe("Good (white at 248 of 255; aiming near 180, at most 249): exposure 124, gain 0, set by the app"); + expect(view.calibration).toBe("Good (white at 248 of 255; aiming near 150, at most 230): exposure 124, gain 0, set by the app"); }); it("doesn't look like a step blocking the scan when nothing is saved", () => { @@ -121,7 +122,7 @@ describe("calibration at the camera's limit", () => { it("says so, and what to change, when the engine says the camera is at its light limit", () => { const exposure = { ...WEBCAM_GOOD, verdict: "light_limit" } as const; const view = describeStatus(status({ camera: camera({ calibration, at_light_limit: true, exposure }) })); - expect(view.calibration).toBe("At the camera's light limit (white at 120 of 255; aiming near 180, at most 249): " + expect(view.calibration).toBe("At the camera's light limit (white at 120 of 255; aiming near 150, at most 230): " + "brighten the projector, darken the room or move the camera closer"); }); @@ -210,7 +211,7 @@ describe("a still camera's exposure", () => { it("is the camera's own, checked by the engine", () => { const calibration = { exposure: 167, gain: 0, p99: 201.4, manual: true }; expect(describeStatus(status({ camera: camera({ calibration, exposure: STILL_GOOD }) })).calibration) - .toBe("Good (white at 201 of 255; a still camera needs 120 to 249): set on the camera, checked by the app"); + .toBe("Good (white at 201 of 255; a still camera needs 100 to 230): set on the camera, checked by the app"); }); });