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
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
18 changes: 17 additions & 1 deletion engine/calibrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions engine/hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
}
Expand Down
9 changes: 9 additions & 0 deletions engine/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 4 additions & 6 deletions engine/scan_camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}

Expand Down
59 changes: 36 additions & 23 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -302,47 +303,59 @@ <h2 id="surface-title">Surface</h2>
<div class="row"><meter id="sound-meter" min="0" max="100" value="0" aria-label="Sound level"></meter> <span id="sound-note" class="muted">Off</span></div>
</details>
<details class="fold" data-fold="camera"><summary><h2>Camera</h2></summary>
<h3>Before scanning</h3>
<p class="muted hint">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.</p>
<div class="row">
<button id="preview-toggle" aria-describedby="camera-reason">Show preview</button>
<button id="calibrate" aria-describedby="camera-reason">Calibrate exposure</button>
<button id="calibrate" aria-describedby="camera-reason">Check exposure</button>
<button id="check-framing" title="Shows white, then black, and measures how much of the camera's view the projection fills: the more, the finer the scan. Outlined on the preview." aria-describedby="camera-reason">Check framing</button>
<span id="camera-reason" class="scan-reason" hidden></span>
</div>
<p id="framing-note" class="muted hint" role="status" hidden></p>
<p id="exposure-note" class="muted hint" role="status" hidden></p>
<dl><dt>Exposure</dt><dd id="calibration">…</dd><dt id="battery-label" hidden>Battery</dt><dd id="battery" hidden></dd></dl>
<div class="row" id="aperture-row" hidden title="Still cameras: the lens opening for scans. f/8 keeps a scene with depth in focus; wider lets in more light but blurs what's nearer or farther than the focus. Needs the mode dial on M. Applies to the next scan.">
<select id="aperture" aria-label="Aperture for scans">
<option value="camera">Aperture: as set on the camera</option>
<option value="4">Aperture: f/4</option>
<option value="5.6">Aperture: f/5.6</option>
<option value="8">Aperture: f/8 (deep focus)</option>
<option value="11">Aperture: f/11</option>
</select>
</div>
<div class="row" id="hdr-row" title="Capture each pattern at more than one exposure and keep the best-lit reading per pixel: helps when dark and bright surfaces share the scene. Slower. A still camera brackets by itself: 3 photos, 2 stops apart, per pattern. Applies to the next scan.">
<select id="hdr" aria-label="HDR scan">
<option value="1">HDR: off</option>
<option value="2">HDR: 2 exposures</option>
<option value="3">HDR: 3 exposures (slowest)</option>
</select>
</div>
<label class="control" title="How wide a gap between decoded pixels the scan fills in: more gives fuller surfaces, less gives truer edges. Applies to the next scan.">
<span>Hole fill</span><span id="hole-fill-readout" class="muted">9 px</span>
<input id="hole-fill" type="range" min="0" max="31" step="1" value="9" aria-label="Hole fill" />
</label>
<p id="preview-note" class="muted hint" role="status"></p>
<div id="preview-box" hidden>
<img id="preview" alt="Camera preview" />
<svg id="framing-overlay" viewBox="0 0 1 1" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg"><polygon /></svg>
<svg id="mask-overlay" viewBox="0 0 1 1" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg"></svg>
</div>
<div class="row" title="Skip part of what the camera sees: draw around a window, a TV or shiny things, and the next scan leaves them out. Click corners on the preview, double-click or Enter to finish; add as many areas as you like. Click a shaded area to stop skipping it.">
<button id="mask-draw" aria-describedby="mask-reason">Skip an area</button>
<h3 id="scan-settings-camera">Scan settings</h3>
<p class="muted hint">Saved for this camera. Used by the next scan.</p>
<div id="aperture-row" hidden>
<label class="control"><span>Aperture</span>
<select id="aperture" aria-describedby="aperture-hint">
<option value="camera">As set on the camera</option>
<option value="4">f/4</option>
<option value="5.6">f/5.6</option>
<option value="8">f/8 (deep focus)</option>
<option value="11">f/11</option>
</select>
</label>
<p id="aperture-hint" class="muted hint">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.</p>
</div>
<div id="hdr-row">
<label class="control"><span>HDR</span>
<select id="hdr" aria-describedby="hdr-hint">
<option value="1">Off</option>
<option value="2">2 exposures</option>
<option value="3">3 exposures (slowest)</option>
</select>
</label>
<p id="hdr-hint" class="muted hint">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.</p>
</div>
<label class="control">
<span>Hole fill</span><span id="hole-fill-readout" class="muted">9 px</span>
<input id="hole-fill" type="range" min="0" max="31" step="1" value="9" aria-describedby="hole-fill-hint" />
</label>
<p id="hole-fill-hint" class="muted hint">Fills small gaps in the scan. More: fuller surfaces. Less: truer edges.</p>
<div class="row">
<button id="mask-draw" aria-describedby="mask-hint mask-reason">Skip an area</button>
<button id="mask-clear" hidden>Skip nothing</button>
<span id="mask-note" class="muted"></span>
<span id="mask-reason" class="scan-reason" hidden></span>
</div>
<p id="mask-hint" class="muted hint">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.</p>
</details>
<details class="fold" data-fold="test-frame"><summary><h2>Test frame</h2></summary>
<div class="row">
Expand Down
Loading
Loading