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..c9e095a 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 @@
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.
Saved for this camera. Used by the next scan.
+The lens opening. f/8 keeps a deep scene in focus; wider lets in more light but blurs what's nearer or farther. Needs the mode dial on M.
+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.