Camera is now configured by per-mode files; quality gate removed

This commit is contained in:
pgdalmeida 2026-08-26 15:52:47 +02:00
parent 02d3f573cf
commit f1342965de
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
50 changed files with 1141 additions and 2498 deletions

View File

@ -60,10 +60,7 @@ add_library(fgc_core STATIC
src/core/Calibration.cpp src/core/Calibration.cpp
src/core/CalibrationRoutine.cpp src/core/CalibrationRoutine.cpp
src/core/MtiProtocol.cpp src/core/MtiProtocol.cpp
src/core/ImageQuality.cpp src/core/CameraMode.cpp
src/core/ExposurePolicy.cpp
src/core/ExposureStore.cpp
src/core/GatedCameraSource.cpp
src/sensors/Sht41Protocol.cpp src/sensors/Sht41Protocol.cpp
src/ui/UiSnapshot.cpp src/ui/UiSnapshot.cpp
src/ui/Scroll.cpp src/ui/Scroll.cpp

View File

@ -67,7 +67,7 @@ and `--stop` drive it afterwards without re-syncing.
**Only one instance may run at a time.** The binary takes an exclusive `flock` on **Only one instance may run at a time.** The binary takes an exclusive `flock` on
`/run/user/<uid>/fire_gimbal_control.lock` (or `/tmp/fire_gimbal_control-<uid>.lock`) at startup and `/run/user/<uid>/fire_gimbal_control.lock` (or `/tmp/fire_gimbal_control-<uid>.lock`) at startup and
exits with status 2 and `another instance is already running (PID N)` if it is taken — two instances exits with status 2 and `another instance is already running (PID N)` if it is taken — two instances
would fight over the serial port, the camera and the exposure store. The check is in the binary, so a would fight over the serial port and the camera. The check is in the binary, so a
bare `./build/fire_gimbal_control` is refused too. Nothing needs cleaning up after a crash or a power bare `./build/fire_gimbal_control` is refused too. Nothing needs cleaning up after a crash or a power
cut: the kernel drops the lock when the process dies. `--no-lock` bypasses it for a second mock-only cut: the kernel drops the lock when the process dies. `--no-lock` bypasses it for a second mock-only
instance during development. instance during development.
@ -135,6 +135,7 @@ While running, the program reads commands from stdin (one per line):
| `debug` | Toggle debug-level logging on/off. | | `debug` | Toggle debug-level logging on/off. |
| `set fps <n>` | Capture rate, in images per second. | | `set fps <n>` | Capture rate, in images per second. |
| `set camera fps <n>` | Camera sensor frame rate. | | `set camera fps <n>` | Camera sensor frame rate. |
| `set camera mode <name>` | Switch the camera mode (`standard`, `night`, or any other file in `[Camera] mode_dir`). |
| `set camera jxlq <d>` | JPEG XL quality as butteraugli **distance** (lower = higher quality / larger files). | | `set camera jxlq <d>` | JPEG XL quality as butteraugli **distance** (lower = higher quality / larger files). |
| `set camera jxle <n>` | JPEG XL encoder effort (higher = slower, smaller). | | `set camera jxle <n>` | JPEG XL encoder effort (higher = slower, smaller). |
| `set camera display <0\|1>` | Toggle the local preview window. | | `set camera display <0\|1>` | Toggle the local preview window. |
@ -143,13 +144,16 @@ While running, the program reads commands from stdin (one per line):
| `help [topic]` | List commands / expand one section. | | `help [topic]` | List commands / expand one section. |
| `exit` | Stop everything and quit (Ctrl-D also works). | | `exit` | Stop everything and quit (Ctrl-D also works). |
**Image capture** is per-waypoint and self-correcting: at each settled waypoint the camera is **Image capture** runs the camera continuously and keeps one frame per waypoint. The camera free-runs
software-triggered for one deliberate frame, which is then measured (brightness, blown highlights, at the active mode's rate with its own auto-exposure tracking the light; almost every frame is
sharpness) and re-shot with corrected exposure/gain until it passes. Accepted settings are remembered discarded, and their job is simply to keep that exposure controller converged. At each waypoint the
**per heading**, so the next visit starts from a value known to work there instead of waiting for the gimbal settles, holds still for the mode's `settle_wait_ms`, and the newest frame is kept — so the saved
camera's own auto-exposure to re-converge. If the retry budget runs out the best attempt is kept and image was exposed at rest and already correctly metered, with nothing waiting on a convergence loop.
flagged `degraded` rather than losing the waypoint. The reasoning behind each rule is in
[docs/architecture.md](docs/architecture.md#image-capture); the knobs are `[Capture]` in Which rate and which exposure limits apply comes from a **camera mode**: one INI file per mode, listing
Vimba X features applied verbatim. `standard` runs 10 Hz for daylight, `night` runs 1 Hz with long
exposures; switch live with `set camera mode <name>`. Details in
[docs/architecture.md](docs/architecture.md#image-capture); the mode-file format is `[Camera]` in
[docs/configuration.md](docs/configuration.md). [docs/configuration.md](docs/configuration.md).
The **Xsens MTi IMU** (set `[Features] enable_imu` + `[IMU] device`) drives the Sensors panel's live The **Xsens MTi IMU** (set `[Features] enable_imu` + `[IMU] device`) drives the Sensors panel's live

View File

@ -0,0 +1,110 @@
; Low-light camera mode for the RGB Alvium 1800 U-2040c: continuous 1 Hz
; acquisition with long exposures and a high gain ceiling.
;
; Standalone: mode files do not inherit from one another, so this repeats
; standard.ini except where the two deliberately differ (marked NIGHT below).
;
; [Camera] is a list of Vimba X features written to the camera IN FILE ORDER
; when this mode is applied. Names and enum values must be spelled exactly as
; VimbaX spells them (Minimizenoise, not MinimizeNoise) - see a VimbaX settings
; export for the full feature list. A name this camera does not expose is logged
; as a warning and skipped, never fatal.
;
; Selector features are stateful: set the selector, then the value it selects.
; Repeating a key is intentional and supported.
[Mode]
description = Low light: 1 Hz, long exposures, high gain ceiling
; Wait this long after the gimbal reports standstill, then keep the newest frame.
; NIGHT: at 1 fps a 1 s wait need not contain a whole fresh frame; allow two periods.
settle_wait_ms = 2500
; Give up on the waypoint if no fresh frame arrives within this long after that.
frame_timeout_ms = 5000
[Camera]
; --- Image format: binning first, it changes the ROI maxima ----------------
; Same geometry as standard so both modes produce comparable images; 2x2 binning
; also sums four photosites, which is worth having in the dark.
BinningSelector = Digital
BinningHorizontalMode = Sum
BinningVerticalMode = Sum
BinningHorizontal = 2
BinningVertical = 2
OffsetX = 0
OffsetY = 0
Width = 2256
Height = 2256
ReverseX = 0
ReverseY = 0
PixelFormat = RGB8
SensorBitDepth = Bpp12 ; NIGHT: more tonal range where signal is scarce
ColorInterpolation = HighQualityLinear5X5
; --- Bandwidth: 1 Hz x 15.3 MB = ~15 MB/s ----------------------------------
; Do NOT raise to 450: that produced incomplete frames on this host.
DeviceLinkThroughputLimitMode = On
DeviceLinkThroughputLimit = 250000000
; --- Acquisition: free-run, no trigger -------------------------------------
AcquisitionMode = Continuous
TriggerSelector = FrameStart
TriggerMode = Off
AcquisitionFrameRateMode = Basic
AcquisitionFrameRateEnable = 1
AcquisitionFrameRate = 1 ; NIGHT
; --- Exposure and gain: camera-managed, bounded ----------------------------
ExposureMode = Timed
ExposureAuto = Continuous
ExposureAutoMin = 1000
ExposureAutoMax = 500000 ; NIGHT: 0.5 s, inside the 1 Hz frame period
GainSelector = All
GainAuto = Continuous
GainAutoMin = 0
GainAutoMax = 48 ; NIGHT
; --- Auto-exposure controller ----------------------------------------------
IntensityControllerSelector = IntensityController1
IntensityControllerAlgorithm = Mean
IntensityControllerTarget = 50 ; percent of full scale
IntensityControllerTolerance = 5
IntensityControllerRate = 100
IntensityControllerRegion = FullImage
IntensityControllerOutliersDark = 0
IntensityControllerOutliersBright = 0
IntensityAutoPrecedence = Minimizenoise ; NIGHT: expose longer before adding gain
AutoModeRegionSelector = AutoModeRegion1
AutoModeRegionOffsetX = 0
AutoModeRegionOffsetY = 0
AutoModeRegionWidth = 2256
AutoModeRegionHeight = 2256
; --- White balance: fixed ratios, measured in VimbaX ------------------------
BalanceWhiteAuto = Off
BalanceRatioSelector = Red
BalanceRatio = 2.870117
BalanceRatioSelector = Blue
BalanceRatio = 1.999023
; --- Image processing -------------------------------------------------------
BlackLevelSelector = All
BlackLevel = 81
Gamma = 1.0
Hue = 0
Saturation = 1.0
Sharpness = 0
ContrastEnable = 0
ContrastBrightLimit = 255
ContrastDarkLimit = 0
ContrastShape = 4
ColorTransformationEnable = 0
AdaptiveNoiseSuppressionFactor = 1 ; raise if frames look noisy
CorrectionSelector = DefectPixelCorrection
CorrectionMode = On
CorrectionSelector = FixedPatternNoiseCorrection
CorrectionMode = On
; --- Device -----------------------------------------------------------------
DeviceIndicatorMode = Off ; NIGHT: no status LED glow at a dark site
DeviceIndicatorLuminance = 0
DevicePowerSavingMode = Disabled

View File

@ -0,0 +1,106 @@
; Daytime camera mode for the RGB Alvium 1800 U-2040c: continuous 10 Hz
; acquisition with camera-managed exposure.
;
; [Camera] is a list of Vimba X features written to the camera IN FILE ORDER
; when this mode is applied. Names and enum values must be spelled exactly as
; VimbaX spells them (Minimizenoise, not MinimizeNoise) - see a VimbaX settings
; export for the full feature list. A name this camera does not expose is logged
; as a warning and skipped, never fatal.
;
; Selector features are stateful: set the selector, then the value it selects.
; Repeating a key is intentional and supported.
[Mode]
description = Daytime sweep: continuous 10 Hz, camera-managed exposure
; Wait this long after the gimbal reports standstill, then keep the newest frame.
settle_wait_ms = 1000
; Give up on the waypoint if no fresh frame arrives within this long after that.
frame_timeout_ms = 2000
[Camera]
; --- Image format: binning first, it changes the ROI maxima ----------------
; 2x2 binning is not optional at 10 Hz: a full-resolution RGB8 frame is 61 MB,
; which caps out at ~4 fps under the 250 MB/s link limit. Binned it is 15.3 MB.
BinningSelector = Digital
BinningHorizontalMode = Sum
BinningVerticalMode = Sum
BinningHorizontal = 2
BinningVertical = 2
OffsetX = 0
OffsetY = 0
Width = 2256
Height = 2256
ReverseX = 0
ReverseY = 0
PixelFormat = RGB8
SensorBitDepth = Bpp8
ColorInterpolation = HighQualityLinear5X5
; --- Bandwidth: 10 Hz x 15.3 MB = ~153 MB/s --------------------------------
; Do NOT raise to 450: that produced incomplete frames on this host.
DeviceLinkThroughputLimitMode = On
DeviceLinkThroughputLimit = 250000000
; --- Acquisition: free-run, no trigger -------------------------------------
AcquisitionMode = Continuous
TriggerSelector = FrameStart
TriggerMode = Off
AcquisitionFrameRateMode = Basic
AcquisitionFrameRateEnable = 1
AcquisitionFrameRate = 10
; --- Exposure and gain: camera-managed, bounded ----------------------------
ExposureMode = Timed
ExposureAuto = Continuous
ExposureAutoMin = 200
ExposureAutoMax = 20000 ; < 1/fps and short enough to limit slew blur
GainSelector = All
GainAuto = Continuous
GainAutoMin = 0
GainAutoMax = 24
; --- Auto-exposure controller ----------------------------------------------
IntensityControllerSelector = IntensityController1
IntensityControllerAlgorithm = Mean
IntensityControllerTarget = 50 ; percent of full scale
IntensityControllerTolerance = 5
IntensityControllerRate = 100
IntensityControllerRegion = FullImage
IntensityControllerOutliersDark = 0
IntensityControllerOutliersBright = 0
IntensityAutoPrecedence = Minimizeexposuretime
AutoModeRegionSelector = AutoModeRegion1
AutoModeRegionOffsetX = 0
AutoModeRegionOffsetY = 0
AutoModeRegionWidth = 2256
AutoModeRegionHeight = 2256
; --- White balance: fixed ratios, measured in VimbaX ------------------------
BalanceWhiteAuto = Off
BalanceRatioSelector = Red
BalanceRatio = 2.870117
BalanceRatioSelector = Blue
BalanceRatio = 1.999023
; --- Image processing -------------------------------------------------------
BlackLevelSelector = All
BlackLevel = 81
Gamma = 1.0
Hue = 0
Saturation = 1.0
Sharpness = 0
ContrastEnable = 0
ContrastBrightLimit = 255
ContrastDarkLimit = 0
ContrastShape = 4
ColorTransformationEnable = 0
AdaptiveNoiseSuppressionFactor = 1 ; raise if frames look noisy
CorrectionSelector = DefectPixelCorrection
CorrectionMode = On
CorrectionSelector = FixedPatternNoiseCorrection
CorrectionMode = On
; --- Device -----------------------------------------------------------------
DeviceIndicatorMode = Active
DeviceIndicatorLuminance = 10
DevicePowerSavingMode = Disabled

View File

@ -29,30 +29,18 @@ id_Cam1 = DEV_0000000000
id_Cam2 = id_Cam2 =
id_Cam3 = id_Cam3 =
id_Cam4 = id_Cam4 =
; --- On-camera imaging (Alvium; applied in-session at startup) --- ; --- Camera modes ---
; The LattePanda USB3 host cannot reliably transfer large single frames (~60 MB RGB8 ; Every on-camera setting (acquisition rate, exposure limits, binning, ROI, image
; full-res stalls); keep frames small and PACE capture. See docs/known-issues.md. ; processing) lives in a mode file: a mode named X is <mode_dir>/X.ini. Nothing
; Digital binning: 1 = full res; 2 = 2x2 (~5 MP, ~15 MB) - the reliable default. ; about the sensor is configured here, so the two can never disagree.
binning = 2 ; A relative path resolves against the directory holding the config.ini IN USE -
; ROI (0 = sensor maximum / full frame). Set width/height to crop. ; which is the repo root on the device (deploy.sh copies this file there), not
offset_x = 0 ; this config/ directory.
offset_y = 0 mode_dir = config/camera_modes/rgb
width = 0 ; Which mode to start in. Switch live with `set camera mode <name>`.
height = 0 ; Ships with: standard (10 Hz daytime), night (1 Hz, long exposures).
; Pixel format: RGB8 keeps de-Bayering AND white balance on-camera (colour out, no host debayer). mode = standard
pixel_format = RGB8
; DeviceLinkThroughputLimit in MByte/s. Do NOT max it (450 caused incomplete frames); ~200-300.
throughput_mbytes = 250
; Paced acquisition rate (fps). Keep LOW: ~2 fps of 15 MB frames stalls this USB3 host; 1 fps
; sustains. Low rate still keeps the on-camera auto exposure/gain/white-balance converged.
stream_fps = 1.0
; On-camera auto adjustments to track dramatically changing outdoor light. *_max caps bound
; motion blur / frame time (exposure) and noise (gain); 0 = leave the camera default.
exposure_auto = true
exposure_max_us = 0
gain_auto = true
gain_max_db = 0
white_balance_auto = true
; JPEG XL encoding. distance: 0 = lossless, ~0.8 = near-lossless (default); effort 1..9. ; JPEG XL encoding. distance: 0 = lossless, ~0.8 = near-lossless (default); effort 1..9.
jxl_distance = 0.8 jxl_distance = 0.8
jxl_effort = 4 jxl_effort = 4
@ -61,70 +49,6 @@ jxl_effort = 4
; (Builds before this key existed always rotated 90 CCW, i.e. 270.) ; (Builds before this key existed always rotated 90 CCW, i.e. 270.)
rotation_deg = 0 rotation_deg = 0
[Capture]
; How a frame is acquired at each waypoint, and the quality bar it must clear.
;
; mode = trigger : one deliberate software-triggered frame per attempt (default).
; The image belongs to the waypoint that asked for it, and its
; exposure can be chosen deliberately. Also a LOWER USB load than
; a continuous stream.
; mode = freerun : the older paced stream + keep-latest. Fallback only.
mode = trigger
; Judge each frame and reshoot with corrected exposure until it passes. Turning
; this off keeps the first frame unconditionally and disables the per-angle store.
quality_gate = true
; Retry budget per waypoint. min_attempts > 1 always shoots extra and keeps the
; sharpest - worth raising at a windy site.
max_attempts = 3
min_attempts = 1
acquire_timeout_ms = 2000
; Pause after the axes report standstill, before the first shot: a trigger fired
; the instant motion stops still catches the tail of it.
settle_delay_ms = 150
; --- Exposure target ---
; Desired mean brightness (0..255) and the band that counts as good enough.
target_mean = 110
mean_tolerance = 12
; Ceiling on blown-out pixels (fraction). This OUTRANKS the mean: a bright sky can
; saturate while the average still looks fine, and blown highlights are gone for good.
clip_max_fraction = 0.005
exposure_min_us = 50
; Correction damping (0..1]; below 1 trades a little speed for stability.
damping = 0.8
; Cold-start settings, used only when the store has nothing to offer.
default_exposure_us = 5000
default_gain_db = 0
; NOTE: the exposure CEILING and the gain cap come from [Camera] exposure_max_us and
; gain_max_db. The exposure ceiling doubles as the motion-blur budget.
; --- Blur ---
; Sharpness is judged RELATIVE to the same angle's own history, because a foggy or
; featureless horizon is legitimately low-detail and an absolute threshold would
; retry forever on it. Reject below this fraction of the angle's last good score.
blur_relative_floor = 0.5
; Catastrophic-blur backstop, in absolute variance-of-Laplacian units. 0 = off.
blur_absolute_floor = 0
; --- Metric cost ---
; Exposure/clipping stats sample every Nth pixel; sharpness always runs at full
; resolution on a centre square of sharpness_roi_px.
metric_stride = 4
sharpness_roi_px = 512
; --- Per-angle exposure memory ---
; Remembers what worked at each heading so the next visit starts from a setting
; known to be roughly right, instead of paying the camera's auto-exposure several
; frames to re-converge. Empty path => $XDG_DATA_HOME/fire_gimbal_control/exposure_store.csv
exposure_store =
; Angle bucket size in degrees (headings within one bucket share a seed).
angle_quantum_deg = 1.0
; How old an entry may be and still be trusted (seconds). Past this, the seed falls
; back to the last settings accepted anywhere - outdoor light drifts, and a recent
; reading one heading over beats an hour-old reading at this exact heading.
store_stale_s = 1800
[Serial] [Serial]
; Motor-controller serial device and baud rate. ; Motor-controller serial device and baud rate.
device = /dev/ttyACM0 device = /dev/ttyACM0

View File

@ -14,10 +14,17 @@
# ./deploy.sh --status is it running on the device? # ./deploy.sh --status is it running on the device?
# ./deploy.sh --stop shut the running app down cleanly # ./deploy.sh --stop shut the running app down cleanly
# ./deploy.sh --clean wipe the remote build dir first (fresh configure) # ./deploy.sh --clean wipe the remote build dir first (fresh configure)
# ./deploy.sh --push-config ALSO overwrite the device's config.ini with the local one
# ./deploy.sh --check-deps only check the remote build dependencies # ./deploy.sh --check-deps only check the remote build dependencies
# ./deploy.sh --run --force skip the placeholder-config guard before running # ./deploy.sh --run --force skip the placeholder-config guard before running
# ./deploy.sh --run --foreground run attached to this terminal instead (debugging) # ./deploy.sh --run --foreground run attached to this terminal instead (debugging)
# #
# Everything under config/ (the example, the camera-mode files, scan grids) is
# mirrored on every deploy. The device's own config.ini at the repo root is NOT —
# it holds the live calibration, camera IDs and credentials for that rig, so a
# routine deploy must never clobber it. --push-config opts into overwriting it,
# backing the device copy up to config.ini.bak-<timestamp> first.
#
# --run starts the app inside a tmux session on the device (scripts/fgc), so it # --run starts the app inside a tmux session on the device (scripts/fgc), so it
# survives this ssh connection closing — reattach later with --attach. Only one # survives this ssh connection closing — reattach later with --attach. Only one
# instance can run at a time; the binary itself enforces that with a lockfile. # instance can run at a time; the binary itself enforces that with a lockfile.
@ -50,10 +57,12 @@ CHECK_DEPS=0
FORCE=0 FORCE=0
HOME_INIT=0 HOME_INIT=0
FOREGROUND=0 FOREGROUND=0
PUSH_CONFIG=0
SESSION_CMD="" SESSION_CMD=""
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--clean) CLEAN=1 ;; --clean) CLEAN=1 ;;
--push-config|--with-config) PUSH_CONFIG=1 ;;
--run|--start) RUN=1 ;; --run|--start) RUN=1 ;;
--home|--init) HOME_INIT=1 ;; --home|--init) HOME_INIT=1 ;;
--check-deps) CHECK_DEPS=1 ;; --check-deps) CHECK_DEPS=1 ;;
@ -74,6 +83,15 @@ if [[ -n "$SESSION_CMD" && "$SESSION_CMD" != "attach" && "$RUN" == "1" ]]; then
echo "error: --$SESSION_CMD cannot be combined with --run" >&2 echo "error: --$SESSION_CMD cannot be combined with --run" >&2
exit 1 exit 1
fi fi
if [[ "$PUSH_CONFIG" == "1" && ! -f config.ini ]]; then
echo "error: --push-config given but there is no local config.ini to push" >&2
echo " (config.ini is gitignored; the device keeps its own at \$REMOTE_DIR/config.ini)" >&2
exit 1
fi
if [[ "$PUSH_CONFIG" == "1" && -n "$SESSION_CMD" && "$SESSION_CMD" != "attach" ]]; then
echo "error: --push-config cannot be combined with --$SESSION_CMD (which does not sync)" >&2
exit 1
fi
# Talk to the app already on the device — no sync, no build. `--run --attach` is # Talk to the app already on the device — no sync, no build. `--run --attach` is
# the exception: there the attach happens at the very end, after the build and # the exception: there the attach happens at the very end, after the build and
@ -159,28 +177,54 @@ echo ">> ensuring remote dir exists"
ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'" ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'"
# config.ini and images/ are device-local (runtime config + captured output) and # config.ini and images/ are device-local (runtime config + captured output) and
# are deliberately NOT mirrored, so a deploy never clobbers them. build/ is # are deliberately NOT mirrored, so a deploy never clobbers them. Note this
# machine-specific and regenerated remotely. # excludes only the config.ini at the repo root: config/ itself (the example,
# camera-mode files, scan grids) always syncs.
#
# build*/ covers every local build tree, not just build/. They are machine-specific
# and the device regenerates its own build/ remotely, so shipping build-tui/ etc.
# just pushed tens of MB of x86 object files the device can never use.
CONFIG_EXCLUDES=(--exclude 'config.ini')
if [[ "$PUSH_CONFIG" == "1" ]]; then
# The device config holds that rig's calibration, camera IDs and credentials -
# things that exist nowhere else. Keep a copy before overwriting it.
echo ">> backing up the device config.ini before overwriting it"
ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && \
if [ -f config.ini ]; then \
cp -p config.ini \"config.ini.bak-\$(date +%Y%m%d-%H%M%S)\" && \
echo ' saved as config.ini.bak-<timestamp>'; \
else echo ' no existing device config.ini to back up'; fi"
CONFIG_EXCLUDES=()
echo ">> pushing local config.ini (overwrites the device's)"
fi
echo ">> syncing to $REMOTE_HOST:$REMOTE_DIR" echo ">> syncing to $REMOTE_HOST:$REMOTE_DIR"
rsync -avz --delete \ rsync -avz --delete \
--exclude 'build/' \ --exclude 'build*/' \
--exclude '.git' \ --exclude '.git' \
--exclude '.venv' \ --exclude '.venv' \
--exclude '.vscode' \ --exclude '.vscode' \
--exclude '__pycache__' \ --exclude '__pycache__' \
--exclude '.deploy.env' \ --exclude '.deploy.env' \
--exclude 'config.ini' \ "${CONFIG_EXCLUDES[@]}" \
--exclude 'config.ini.bak-*' \
--exclude 'images/' \ --exclude 'images/' \
./ "$REMOTE_HOST:$REMOTE_DIR/" ./ "$REMOTE_HOST:$REMOTE_DIR/"
# Seed a config.ini on the device from the example if it doesn't have one yet, # Seed a config.ini on the device from the example if it doesn't have one yet,
# so the first run finds a config. Never overwrites an existing device config. # so the first run finds a config. Never overwrites an existing device config.
# Skipped entirely under --push-config: the rsync above just placed one, and the
# "keeping existing device config.ini" line would be actively misleading there.
if [[ "$PUSH_CONFIG" == "1" ]]; then
echo ">> device config.ini is the one just pushed (previous copy: config.ini.bak-*)"
else
echo ">> ensuring remote config.ini exists" echo ">> ensuring remote config.ini exists"
ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && \ ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && \
if [ ! -f config.ini ]; then \ if [ ! -f config.ini ]; then \
cp config/config.example.ini config.ini && \ cp config/config.example.ini config.ini && \
echo ' seeded config.ini from config/config.example.ini — edit it on the device'; \ echo ' seeded config.ini from config/config.example.ini — edit it on the device'; \
else echo ' keeping existing device config.ini'; fi" else echo ' keeping existing device config.ini'; fi"
fi
if [[ "$CLEAN" == "1" ]]; then if [[ "$CLEAN" == "1" ]]; then
echo ">> wiping remote build dir" echo ">> wiping remote build dir"

View File

@ -53,7 +53,7 @@ thread so all geometry/state mutation stays single-threaded.
| Serial I/O | `SerialMotorController` | Boost.Asio `io_context`; async read-until parses telemetry | | Serial I/O | `SerialMotorController` | Boost.Asio `io_context`; async read-until parses telemetry |
| Image worker | `ImagePipeline` | drains the frame queue: rotate → encode → write → publish | | Image worker | `ImagePipeline` | drains the frame queue: rotate → encode → write → publish |
| MQTT client | Paho (internal) | delivers callbacks; auto-reconnects (no busy-wait loop) | | MQTT client | Paho (internal) | delivers callbacks; auto-reconnects (no busy-wait loop) |
| Camera acquisition | Vimba X (internal) | delivers frames via the observer; the gate's shoot/judge/retry loop runs on the **control thread**, so a slow waypoint delays the tick rather than racing it | | Camera acquisition | Vimba X (internal) | free-runs at the mode's frame rate and delivers frames via the observer, which keeps the latest complete one |
Shared state is mutex-guarded: latest `MotorTelemetry` (serial), `ControlCommand` (channel), the frame queue Shared state is mutex-guarded: latest `MotorTelemetry` (serial), `ControlCommand` (channel), the frame queue
(pipeline), the console command queue, and the latest `UiSnapshot`. The `CaptureScheduler` runs only on the (pipeline), the console command queue, and the latest `UiSnapshot`. The `CaptureScheduler` runs only on the
@ -71,14 +71,15 @@ corrupted.
one. Encoder counts are mapped to/from degrees by `Geometry` (`[Motor]` calibration). one. Encoder counts are mapped to/from degrees by `Geometry` (`[Motor]` calibration).
3. **Control input** — `IControlChannel::poll()` returns the latest `ControlCommand` (control code + target 3. **Control input** — `IControlChannel::poll()` returns the latest `ControlCommand` (control code + target
heading), clearing its "available" flags so each update is acted on once. heading), clearing its "available" flags so each update is acted on once.
4. **Capture cycle** (`CaptureScheduler::tick`, per 10 ms) — a move → settle → trigger machine: 4. **Capture cycle** (`CaptureScheduler::tick`, per 10 ms) — a move → settle → wait → keep machine:
- ControlCode 0: `MOVE <yaw>,<pitch>` to the next `ScanGrid` waypoint (front to back, then wrap). - ControlCode 0: `MOVE <yaw>,<pitch>` to the next `ScanGrid` waypoint (front to back, then wrap).
- ControlCode 1: `MOVE` yaw to `target_HDG` (pitch held), converted to counts via `Geometry`. - ControlCode 1: `MOVE` yaw to `target_HDG` (pitch held), converted to counts via `Geometry`.
- Trigger the cameras once **both axes report standstill at the target**, then advance the grid. - Once **both axes report standstill at the target**, wait `settle_wait_ms`, then keep the newest
5. **Acquisition + quality gate** (`GatedCameraSource`) — `trigger()` does not simply take a picture. It frame and advance the grid.
seeds exposure/gain from the per-angle store, software-triggers **one** frame, measures it, corrects and 5. **Acquisition** — the camera free-runs at the active mode's `AcquisitionFrameRate` with its own
re-shoots until it passes or the budget runs out. See *Image capture* below. auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image
6. **Frame handling** — the accepted `Frame` goes to the callback, which `submit()`s it to the capture* below.
6. **Frame handling** — the kept `Frame` goes to the callback, which `submit()`s it to the
`ImagePipeline`. The worker rotates it by `Camera.rotation_deg`, encodes JPEG XL (or copies the demo image), writes `ImagePipeline`. The worker rotates it by `Camera.rotation_deg`, encodes JPEG XL (or copies the demo image), writes
`<output_dir>/<label>/<unix_ms>.jxl`, and publishes a `CamEvent` (yaw + pitch from the encoders). `<output_dir>/<label>/<unix_ms>.jxl`, and publishes a `CamEvent` (yaw + pitch from the encoders).
@ -93,9 +94,13 @@ corrupted.
└──────────┬───────────┘ deg→counts via Geometry; set moving, reset timer └──────────┬───────────┘ deg→counts via Geometry; set moving, reset timer
│ (both axes standstill AND |xenc − target| ≤ tol) │ (both axes standstill AND |xenc − target| ≤ tol)
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐ the camera keeps streaming throughout; this pause is
│ camera.trigger() │ on success: clear moving, advance grid (ControlCode 0) │ wait settle_wait_ms │ what makes the kept frame one exposed at rest
└──────────────────────┘ on failure: retry next tick, do NOT advance └──────────┬───────────┘
▼
┌──────────────────────┐ on success: clear moving, advance grid (ControlCode 0)
│ camera.trigger() │ no fresh frame: retry next tick
└──────────────────────┘ still none after frame_timeout_ms: warn, skip, advance
``` ```
Triggering only at the settled target replaces the original's trigger-while-moving behaviour (see Triggering only at the settled target replaces the original's trigger-while-moving behaviour (see
@ -107,91 +112,71 @@ everything below is an implementation detail of the camera source. That is what
## Image capture ## Image capture
### Why not just take a picture ### The model: free-run, then hold still
The first implementation streamed continuously at 1 fps and kept whichever frame arrived most recently The camera **runs continuously** at the mode's frame rate with its own auto-exposure and auto-gain in
("paced free-run + keep-latest"). Two problems followed. The frame saved at a waypoint was captured at some `Continuous`. Almost every frame it produces is thrown away. Their job is not to be saved — it is to keep
unknown moment *before* the axes finished settling, and its exposure had been metered for wherever the camera the camera's exposure controller converged on the current light, so that the one frame we do keep is
happened to be pointing at the time — so brightness swung wildly between waypoints as the horizon changed. already correct without anyone waiting for it.
And nothing checked that the saved image was usable at all.
The camera's own continuous auto-exposure cannot fix this: it needs **several frames to converge** every time At each waypoint the scheduler therefore:
the scene changes, and a scan changes scene at every waypoint. Waiting for it costs exactly the time the scan
is trying to save.
### The current model 1. moves, and waits for both axes to report standstill at the target;
2. waits a further `settle_wait_ms` (1 s in `standard`, 2.5 s in `night`);
3. keeps the newest complete frame.
One deliberate frame per attempt, judged before it is kept: The wait is the whole mechanism. It guarantees the kept frame was exposed with the gimbal **at rest** (no
slew blur) and after the auto-exposure has seen this waypoint's light for several frames. It also sets the
floor on mode frame rate: a mode must produce comfortably more than one frame inside its own settle wait,
which is why `night` at 1 Hz waits 2.5 s where `standard` at 10 Hz waits 1 s.
``` If no fresh frame has arrived by `frame_timeout_ms` past the wait, the waypoint is logged and skipped. A
seed exposure/gain from the per-angle store stalled camera costs single images, never the whole sweep.
│
▼
┌──────────────────────────┐
│ setExposure / setGain │
│ TriggerSoftware → 1 frame│ attempt 1..max_attempts
└────────────┬─────────────┘
▼
┌──────────────────────────┐ mean luma, clipped %, dark %, sharpness
│ analyzeFrame (metrics) │ + gain read back from the camera
└────────────┬─────────────┘
▼
┌──────────────────────────┐ accept → deliver
│ evaluate (verdict) │ reject → corrected settings, shoot again
└──────────────────────────┘ budget spent → deliver BEST attempt, flag degraded
```
Layering, all behind `ICameraSource` so the scheduler is unaffected: ### Camera modes
| Piece | Role | Everything on the camera comes from a **mode**: one INI file per mode, `<mode_dir>/<name>.ini`, holding an
|---|---| ordered list of Vimba X features written verbatim at start-up and on every switch. There is no C++ struct of
| [`ImageQuality`](../include/fgc/ImageQuality.h) | Pure metrics over the raw buffer. No OpenCV, so it lives in `fgc_core` and is testable on synthetic frames | camera settings and no translation layer — a feature the camera exposes can be set by adding a line, and
| [`ExposurePolicy`](../include/fgc/ExposurePolicy.h) | Pure control law: verdict + corrected settings + a score for ranking attempts | `Feature::GetDataType()` tells the applier whether to write it as an int, float, enum, string or bool.
| [`ExposureStore`](../include/fgc/ExposureStore.h) | Per-angle memory of what worked, persisted as CSV |
| [`GatedCameraSource`](../include/fgc/GatedCameraSource.h) | Decorator wiring the three around a real camera |
Because the gate depends only on the interface plus pure modules, the whole retry loop is unit-tested against Two properties of the file are load-bearing, and both are why the mode loader does not reuse the ordinary
a scripted fake camera — no hardware, no OpenCV ([tests/test_gatedcamera.cpp](../tests/test_gatedcamera.cpp)). config parser (which flattens into a `std::map`):
### The reasoning behind each rule - **Order is preserved.** GenICam selectors are stateful: `BinningHorizontal` means nothing until
`BinningSelector` is set, and binning must precede `Width`/`Height` because it changes their maxima.
- **Repeated keys are kept.** Writing both white-balance ratios *requires* `BalanceRatioSelector = Red`,
`BalanceRatio = …`, `BalanceRatioSelector = Blue`, `BalanceRatio = …`.
**Clipping outranks brightness.** A bright sky can saturate while the mean still looks perfectly reasonable, A feature this camera does not expose is a warning, not a fatal error — that warning is the feedback loop
and blown highlights are unrecoverable — a slightly dark frame is not. So clipping is checked first and for a hand-edited mode file. A *structural* error (unknown section, unknown `[Mode]` key, a mode with no
forces exposure down regardless of the mean. features) is fatal, and every mode file in the directory is parsed at start-up, so a typo in a mode nobody
uses today still fails at launch rather than the first night someone switches to it.
**Exposure before gain when brightening; gain before exposure when darkening.** Gain buys brightness with Ships with `standard` (10 Hz daytime) and `night` (1 Hz, long exposures, high gain ceiling). Switch live
noise, so it is the last resort on the way up and the first thing surrendered on the way down. with `set camera mode <name>`; that stops acquisition, rewrites the features and restarts, because binning,
ROI and pixel format cannot be changed on a running stream.
**The exposure ceiling is the motion-blur budget.** A long exposure on a wind-loaded tower is itself a blur ### Why not judge each frame instead
source, so `[Camera] exposure_max_us` bounds both. This couples the two checks: the cure for darkness cannot
be allowed to cause blur. It matters more with a long lens, where angular blur scales with focal length — a
telephoto needs *more* light but tolerates *less* exposure time, and the resolution of that tension is gain
(and its noise), not time.
**Blur is relative, never absolute.** A foggy or featureless horizon is legitimately low-detail; a fixed An earlier design software-triggered one frame per waypoint, measured it (mean luma, clipping, sharpness),
sharpness threshold would retry forever on it. Each frame is compared against *the same angle's own* recent corrected exposure and re-shot until it passed, seeded from a per-angle exposure store. It was removed. It
sharpness, which is scene-independent: fog stays fog, but a gust or an early trigger shows as a sudden duplicated — and fought — the camera's own exposure controller, which meters continuously in hardware and
collapse. Blur never re-meters — reshooting is the fix, not a different exposure. does not need the scan to stop for it. Its premise was that the camera's auto-exposure needs several frames
to converge and a scan changes scene at every waypoint; free-running at 10 Hz supplies those frames for
free, during the slew, at no cost to scan rate.
**Pinned at the limits means accept, not retry.** When the proposed correction would not move the camera ### Bandwidth: why both modes bin 2×2
(exposure and gain both at their caps), the verdict is `saturated` and the frame is taken: further attempts
could not do better, and the budget is better spent elsewhere.
**A waypoint is never lost.** If the budget runs out, the best-scoring attempt is delivered anyway and marked At `PixelFormat = RGB8` a full-resolution 4512×4512 frame is **61 MB**. Under the 250 MB/s
`degraded` — in the log, in the TUI `c` panel, and on the MQTT `CamEvent` — so coverage stays complete while `DeviceLinkThroughputLimit` that caps out at ~4 fps, and even at the interface maximum only ~7 — so 10 Hz at
a degraded frame is never mistaken for a good one. full resolution is not reachable in RGB8 at all. (The datasheet's "21 fps at full resolution" is 450 MB/s
**Mono8**.) With `BinningHorizontal/Vertical = 2` the frame is 2256×2256 ≈ 5 MP, 15.3 MB, and 10 Hz needs
~153 MB/s — comfortably inside the cap. Binning also sums four photosites, which is worth having at night.
Raising the throughput limit to 450 is not the fix: it produced incomplete frames on this host
([known-issues.md](known-issues.md)).
**Per-angle memory beats the camera's auto.** Accepted settings are stored per quantised heading, so the next Full parameter reference: `[Camera]` in [configuration.md](configuration.md).
visit starts from a value known to work there instead of re-converging from scratch. Seeding falls through
three tiers: this angle's fresh entry → the last settings accepted *anywhere* (outdoor light drifts, so a
recent reading one heading over predicts current light better than an hour-old reading at this exact heading)
→ the configured defaults. Keying by angle rather than waypoint index means it survives grid edits and also
serves ControlCode 1 directed moves.
**White balance stays on the camera.** It is far less time-critical than exposure and is correctable in post,
unlike a blown or blurred frame.
Full parameter reference: `[Capture]` in [configuration.md](configuration.md).
## Why this shape ## Why this shape

View File

@ -34,39 +34,11 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| `Serial` | `device` | string | `/dev/ttyACM0` | Motor-controller serial device | | `Serial` | `device` | string | `/dev/ttyACM0` | Motor-controller serial device |
| `Serial` | `baud` | int | `115200` | Serial baud rate | | `Serial` | `baud` | int | `115200` | Serial baud rate |
| `Camera` | `id_Cam1`..`id_Cam4` | string | — | Camera IDs (GigE IP or USB `DEV_...`); non-empty ones used in order | | `Camera` | `id_Cam1`..`id_Cam4` | string | — | Camera IDs (GigE IP or USB `DEV_...`); non-empty ones used in order |
| `Camera` | `binning` | int ≥ 1 | `2` | Digital binning; `1` = full res, `2` = 2×2 (~5 MP, ~15 MB — the reliable default) | | `Camera` | `mode_dir` | string | `config/camera_modes/rgb` | Directory of camera-mode files; a mode named X is `<mode_dir>/X.ini`. A relative path resolves against the directory holding the `config.ini` in use |
| `Camera` | `offset_x`/`offset_y` | int | `0` | ROI origin (0 = full frame) | | `Camera` | `mode` | string | `standard` | Mode to start in. Ships with `standard` (10 Hz day) and `night` (1 Hz). Switch live with `set camera mode <name>` |
| `Camera` | `width`/`height` | int | `0` | ROI size in pixels; `0` = sensor maximum |
| `Camera` | `pixel_format` | string | `RGB8` | On-camera format (RGB8 keeps de-Bayer + white balance on-camera) |
| `Camera` | `throughput_mbytes` | int > 0 | `250` | `DeviceLinkThroughputLimit` (MByte/s); do **not** max it (≥450 drops frames) |
| `Camera` | `stream_fps` | double > 0 | `1.0` | Paced acquisition rate, **`[Capture] mode = freerun` only**. Keep low (~2 fps of 15 MB frames stalls this USB3 host; 1 fps sustains) |
| `Camera` | `exposure_auto` | bool | `true` | `ExposureAuto=Continuous`. Forced off when the quality gate owns exposure |
| `Camera` | `exposure_max_us` | double | `0` | Exposure ceiling (µs); `0` = camera default. Also the gate's **motion-blur budget** |
| `Camera` | `gain_auto` | bool | `true` | `GainAuto=Continuous`. Forced off when the quality gate owns exposure |
| `Camera` | `gain_max_db` | double | `0` | Gain cap (dB); `0` = camera default. The gate's **noise ceiling** |
| `Camera` | `white_balance_auto` | bool | `true` | `BalanceWhiteAuto=Continuous` |
| `Camera` | `jxl_distance` | double ≥ 0 | `0.8` | JPEG XL distance (`0` = lossless, `~0.8` = near-lossless) | | `Camera` | `jxl_distance` | double ≥ 0 | `0.8` | JPEG XL distance (`0` = lossless, `~0.8` = near-lossless) |
| `Camera` | `jxl_effort` | int 1..9 | `4` | JPEG XL effort (higher = slower/smaller) | | `Camera` | `jxl_effort` | int 1..9 | `4` | JPEG XL effort (higher = slower/smaller) |
| `Camera` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to saved images (`0` = as the sensor delivers them) | | `Camera` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to saved images (`0` = as the sensor delivers them) |
| `Capture` | `mode` | `trigger`\|`freerun` | `trigger` | Per-waypoint software trigger, or the older paced stream + keep-latest |
| `Capture` | `quality_gate` | bool | `true` | Judge each frame and reshoot with corrected exposure until it passes |
| `Capture` | `max_attempts` | int ≥ 1 | `3` | Retry budget per waypoint |
| `Capture` | `min_attempts` | int 1..max | `1` | Always shoot this many and keep the sharpest (raise at windy sites) |
| `Capture` | `acquire_timeout_ms` | int | `2000` | How long to wait for a triggered frame |
| `Capture` | `settle_delay_ms` | int | `150` | Pause after standstill before the first shot |
| `Capture` | `target_mean` | 0..255 | `110` | Desired mean brightness |
| `Capture` | `mean_tolerance` | double | `12` | Band around `target_mean` that counts as good |
| `Capture` | `clip_max_fraction` | 0..1 | `0.005` | Max blown-out pixels; **outranks the mean** |
| `Capture` | `exposure_min_us` | double | `50` | Exposure floor |
| `Capture` | `damping` | (0..1] | `0.8` | Correction damping; below 1 prevents oscillation |
| `Capture` | `default_exposure_us` / `default_gain_db` | double | `5000` / `0` | Cold-start settings when the store is empty |
| `Capture` | `blur_relative_floor` | double | `0.5` | Reject below this fraction of the angle's own last good sharpness |
| `Capture` | `blur_absolute_floor` | double | `0` | Catastrophic-blur backstop; `0` = off |
| `Capture` | `metric_stride` | int ≥ 1 | `4` | Subsampling for exposure/clipping stats |
| `Capture` | `sharpness_roi_px` | int | `512` | Centre square measured at full resolution for sharpness |
| `Capture` | `exposure_store` | string | `$XDG_DATA_HOME/fire_gimbal_control/exposure_store.csv` | Per-angle exposure memory |
| `Capture` | `angle_quantum_deg` | double | `1.0` | Angle bucket size for the store |
| `Capture` | `store_stale_s` | int | `1800` | How long an entry stays trusted |
| `Paths` | `output_dir` | string | `$XDG_DATA_HOME/fire_gimbal_control/images` | Image output dir; supports `~`/`$ENV` | | `Paths` | `output_dir` | string | `$XDG_DATA_HOME/fire_gimbal_control/images` | Image output dir; supports `~`/`$ENV` |
| `Features` | `enable_mqtt` | bool | `true` | Use MQTT (vs null channel) | | `Features` | `enable_mqtt` | bool | `true` | Use MQTT (vs null channel) |
| `Features` | `enable_camera` | bool | `true` | (reserved) | | `Features` | `enable_camera` | bool | `true` | (reserved) |
@ -166,45 +138,56 @@ The config is read **once at startup** and cached, so if you change the XKF prof
stays stale until you **refresh** it: press `r` (or type `refresh`). That re-queries the device (briefly stays stale until you **refresh** it: press `r` (or type `refresh`). That re-queries the device (briefly
pausing the stream) and also requests a fresh firmware dump for the gimbal `g` view. pausing the stream) and also requests a fresh firmware dump for the gimbal `g` view.
### `[Capture]` — triggered acquisition + image quality gate ### `[Camera]` — camera modes
At each waypoint the scheduler moves, waits for both axes to settle, then asks for an image. Every on-camera setting lives in a **mode file**, one per mode, at `<mode_dir>/<name>.ini`. The mode's
With `mode = trigger` (the default) that is one deliberate `TriggerSoftware` frame, so the image name is the filename stem, so adding a mode is dropping in a file — there is no registry to keep in
belongs to the waypoint that asked for it — unlike the older `freerun` model, which streamed sync. `config.ini` only picks which one to start in; nothing about the sensor is configured in two
continuously and kept whichever frame happened to arrive last, metered for wherever the camera was places.
pointing at the time.
**The gate.** Each frame is measured and judged, and a failure is corrected and re-shot: Two modes ship:
| Check | Failure | Correction | | Mode | Rate | Intent |
|---|---|---| |---|---|---|
| Blown highlights (`clip_max_fraction`) | `clipped` | Shorten exposure. Checked **first**, because a bright sky can saturate while the mean still looks fine, and clipped pixels are unrecoverable | | `standard` | 10 Hz | Daytime sweep. Short exposure ceiling (20 ms) and a modest gain cap; `IntensityAutoPrecedence = Minimizeexposuretime` so the camera buys brightness with gain rather than time |
| Mean brightness (`target_mean` ± `mean_tolerance`) | `dark` / `bright` | Brightening spends **exposure first, gain last** (gain costs noise); darkening does the reverse | | `night` | 1 Hz | Long exposures (up to 0.5 s), gain to 48 dB, `Minimizenoise` so it spends time before noise, 12-bit readout, status LED off |
| Noise | — | Gated by the camera's reported gain against `[Camera] gain_max_db`, not estimated from pixels |
| Sharpness | `blur` | Re-shoot; never re-meters |
Exposure is clamped to `[Camera] exposure_max_us`, which therefore doubles as the **motion-blur **How a mode is applied.** A `[Camera]` block is a list of Vimba X features written verbatim, in file
budget** — a long exposure on a wind-loaded tower is itself a blur source. When the camera is pinned order, at start-up and on every switch. Names and enum values must be spelled exactly as VimbaX spells
at its limits and still cannot reach the target, the verdict is `saturated` and the frame is them (`Minimizenoise`, not `MinimizeNoise`); the easiest source of truth is a settings export from
accepted: retrying could not do better. When the budget runs out, the **best attempt is kept** and VimbaX itself. There is no fixed vocabulary — any feature the camera exposes can be set by adding a
flagged `degraded` — in the log, in the TUI `c` panel, and on the MQTT `CamEvent` — so a waypoint is line, and the type is read off the camera.
never lost but a degraded image is never mistaken for a good one.
**Blur is relative, not absolute.** A foggy or featureless horizon is legitimately low-detail, so a Order and repetition are deliberate and preserved:
fixed sharpness threshold would retry forever on it. Each frame is instead compared against *the same
angle's own* recent sharpness (`blur_relative_floor`): fog stays fog, but a wind gust or an early
trigger shows up as a sudden collapse. Among the attempts taken, the sharpest wins.
**Per-angle memory.** Accepted settings are stored per quantised heading (`exposure_store`), so the - **Order.** GenICam selectors are stateful, so a selector must precede what it selects, and binning
next visit starts from a setting known to work there rather than paying the camera's own must precede `Width`/`Height` because it changes their maxima.
auto-exposure several frames to re-converge. Seeding falls through three tiers: this angle's fresh - **Repeats.** Writing both white-balance ratios requires `BalanceRatioSelector = Red`,
entry → the last settings accepted *anywhere* (outdoor light drifts, so a recent reading one heading `BalanceRatio = …`, `BalanceRatioSelector = Blue`, `BalanceRatio = …`.
over beats an hour-old reading at this exact heading) → the configured defaults. The store is keyed
by angle rather than waypoint index, so it survives edits to the scan grid and also serves
ControlCode 1 directed moves.
White balance stays on the camera (`BalanceWhiteAuto`) even under manual exposure: it is far less **Error handling.** A feature this camera does not expose logs `camera: no such feature '<name>'` and
time-critical and is correctable in post, unlike a blown or blurred frame. is skipped — that warning is how you find a typo in a hand-edited file, so a clean start-up log is the
acceptance test for a mode. Structural mistakes are fatal instead: an unknown section, an unknown
`[Mode]` key, or a mode that sets no features. Every `*.ini` in `mode_dir` is parsed at start-up, so a
broken mode fails at launch rather than the first night someone switches to it.
**The `[Mode]` header** holds the three values the program itself uses:
| Key | Default | Meaning |
|---|---|---|
| `description` | — | Shown in the log and the TUI `c` view |
| `settle_wait_ms` | `1000` | After the axes report standstill, hold this long before keeping a frame |
| `frame_timeout_ms` | `2000` | If no fresh frame arrives within this long after the wait, log and skip the waypoint |
`settle_wait_ms` is the mechanism that makes a free-running camera usable for a stop-and-stare scan: it
guarantees the kept frame was exposed with the gimbal at rest and after the auto-exposure has seen this
waypoint's light. It must comfortably exceed one frame period, which is why `night` at 1 Hz waits 2.5 s
where `standard` at 10 Hz waits 1 s.
**Frame rate is bounded by bandwidth, not the sensor.** A full-resolution RGB8 frame is 61 MB, which
caps out around 4 fps under the 250 MB/s `DeviceLinkThroughputLimit`. Both shipped modes therefore set
`BinningHorizontal/Vertical = 2` (2256×2256 ≈ 5 MP, 15.3 MB), so 10 Hz needs only ~153 MB/s. Raising
the limit to 450 is not a fix — it produced incomplete frames on this host.
### `[Env]` — SHT41 ambient temperature/humidity sensor ### `[Env]` — SHT41 ambient temperature/humidity sensor

View File

@ -162,7 +162,7 @@ scripts/fgc peek # snapshot of the pane without attaching
right while attached. right while attached.
`scripts/fgc stop` sends SIGTERM and waits up to 30s; the program handles it with the same shutdown path `scripts/fgc stop` sends SIGTERM and waits up to 30s; the program handles it with the same shutdown path
as the `exit` command (stop capture, close the camera, save the exposure store). Do **not** `kill -9` — as the `exit` command (stop capture, close the camera, flush the image queue). Do **not** `kill -9` —
an unclean exit leaves the camera wedged for the next run. If it will not stop, attach and look at why. an unclean exit leaves the camera wedged for the next run. If it will not stop, attach and look at why.
`scripts/fgc start` refuses if a run is already up, and prints the previous run's last output if it died `scripts/fgc start` refuses if a run is already up, and prints the previous run's last output if it died
@ -186,7 +186,7 @@ the bottom of the screen. As a last resort `Enter ~ .` closes the ssh client its
### 6c. The single-instance lock ### 6c. The single-instance lock
Two instances would fight over the motor's serial port, the camera, and the exposure store. The binary Two instances would fight over the motor's serial port and the camera. The binary
prevents that itself: at startup it takes an exclusive `flock` on prevents that itself: at startup it takes an exclusive `flock` on
- `/run/user/<uid>/fire_gimbal_control.lock` when that directory exists, else - `/run/user/<uid>/fire_gimbal_control.lock` when that directory exists, else

View File

@ -29,10 +29,8 @@ doctest unit-test suite (`ctest`).
|---|-------|--------| |---|-------|--------|
| 13 | `[Motor]` degrees↔counts calibration | The `config.example.ini` values are **placeholders**. Calibrate `*_counts_per_deg` / `*_zero_count` against real `xenc` readings after homing on the rig. | | 13 | `[Motor]` degrees↔counts calibration | The `config.example.ini` values are **placeholders**. Calibrate `*_counts_per_deg` / `*_zero_count` against real `xenc` readings after homing on the rig. |
| 14 | Capture sweep on hardware | **Mechanically verified on the rig (2026-08-07).** A full `--init --start` run homed both axes and completed `MOVE → settle → trigger` for 15/15 waypoints with 0 dropped frames, 0 stalls and 0 acquisition failures. `kSettleTolCounts` (600) and the per-interval timing in [CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) have **not** been tuned against observed `ST` behaviour yet, and the settle tolerance has not been checked against a sharp image (see #16). | | 14 | Capture sweep on hardware | **Mechanically verified on the rig (2026-08-07).** A full `--init --start` run homed both axes and completed `MOVE → settle → trigger` for 15/15 waypoints with 0 dropped frames, 0 stalls and 0 acquisition failures. `kSettleTolCounts` (600) and the per-interval timing in [CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) have **not** been tuned against observed `ST` behaviour yet, and the settle tolerance has not been checked against a sharp image (see #16). |
| 15 | **Exposure convergence unverified** | The quality gate has never metered a properly-lit scene. On the rig the lens was covered, so every frame pinned at the exposure/gain ceilings and returned `saturated`; the correction path ran (dark → corrected → saturated) but never *converged onto* `target_mean`. Re-run one sweep with the lens uncovered and confirm the mean lands within `mean_tolerance` in ≤2 attempts at most waypoints. | | 15 | **Camera modes unverified on a lit scene** | The `standard`/`night` mode files were derived from a VimbaX export plus the bandwidth arithmetic, not measured against real light. Confirm on the rig that every feature applies without a `camera: no such feature` / `could not set` warning, that `standard` sustains 10 fps with `frames_dropped` at 0, and that the auto-exposure lands on a sensible `ExposureTime` outdoors. The only previous rig test ran with the lens covered. |
| 16 | **Blur check never exercised** | The relative-sharpness test needs a reference score from a genuinely good frame at that angle; the stored values were all noise-level (sharpness ~1–26 on black frames). `blur_relative_floor` (0.5) is therefore an untuned guess. Verify on a lit scene, and specifically that a deliberate disturbance (nudge the rig during a shot, or shorten `settle_delay_ms` to 0) is actually caught. | | 16 | **`settle_wait_ms` untuned** | `standard` waits 1 s and `night` 2.5 s after standstill before keeping a frame. Both are reasoned (≫ one frame period; long enough for the auto-exposure to see the new scene) but not measured. Check on a lit scene that the kept frame shows no slew blur and that its exposure has actually converged for that heading, and shorten the wait if it has. |
| 17 | **`degraded` path never fired on hardware** | Every rig capture either passed or hit `saturated`, so the budget-exhausted branch — best-effort frame + `degraded` flag through log/TUI/`CamEvent` — is covered only by unit tests. Confirm once thresholds are tuned. |
| 18 | **`[Camera] exposure_max_us` / `gain_max_db` unset on the device** | The deployed `config.ini` leaves both at `0`, so the gate falls back to hardcoded 20 ms / 12 dB. Those need real values from a lit scene, and matter more than usual on this rig: the long optical zoom needs more light while tolerating less exposure time (blur scales with focal length). |
| 19 | **USB preconditions do not survive a reboot** | `usbcore.usbfs_memory_mb` reverts to 16 (needs 1000) and USB3 LPM re-enables on the camera's port. **Both were wrong on the LattePanda when tested on 2026-08-07**, and frame delivery stalls without them regardless of application code. Needs the udev rule + kernel cmdline persistence described under *Camera acquisition* below. | | 19 | **USB preconditions do not survive a reboot** | `usbcore.usbfs_memory_mb` reverts to 16 (needs 1000) and USB3 LPM re-enables on the camera's port. **Both were wrong on the LattePanda when tested on 2026-08-07**, and frame delivery stalls without them regardless of application code. Needs the udev rule + kernel cmdline persistence described under *Camera acquisition* below. |
## Camera acquisition (USB3) — root cause + approach ## Camera acquisition (USB3) — root cause + approach
@ -55,34 +53,34 @@ session. Two problems were root-caused (both **host/USB3-side**, reproduced in A
20 MB → 21 frames/20 s; 3.8 MB → 41/20 s). Freerun at max rate stalls even for medium frames. 20 MB → 21 frames/20 s; 3.8 MB → 41/20 s). Freerun at max rate stalls even for medium frames.
- Also raise `usbcore.usbfs_memory_mb` to 1000 (persist via kernel cmdline; resets to 16 on reboot). - Also raise `usbcore.usbfs_memory_mb` to 1000 (persist via kernel cmdline; resets to 16 on reboot).
**Approach (config-driven, see `[Camera]`/`[Capture]` in [configuration.md](configuration.md)):** RGB8 + **Approach (mode-file-driven, see `[Camera]` in [configuration.md](configuration.md)):** RGB8 +
**2×2 binning** (~5 MP, ~15 MB) keeps de-Bayering **and** white balance on-camera (the on-camera pipeline **2×2 binning** (~5 MP, ~15 MB) keeps de-Bayering **and** white balance on-camera (the on-camera pipeline
applies white balance before de-Bayering). `DeviceLinkThroughputLimit` is kept conservative (~250 MB/s; applies white balance before de-Bayering). `DeviceLinkThroughputLimit` is kept conservative (~250 MB/s;
≥450 caused incomplete frames). Full 20 MP is possible via BayerRG8 (20 MB) + a host de-Bayer step if ≥450 caused incomplete frames). Full 20 MP is possible via BayerRG8 (20 MB) + a host de-Bayer step if
reduced resolution proves insufficient. reduced resolution proves insufficient.
**Acquisition is now per-waypoint software trigger** (`[Capture] mode = trigger`), not the paced stream. **Acquisition is a continuous free-running stream** at the active mode's `AcquisitionFrameRate`; the
Note what the measurements above do and do not show: they bound **frame size and pacing**, not trigger scheduler stops at each waypoint, waits `settle_wait_ms`, and keeps the newest frame. This replaced a
mode. The earlier conclusion that "software trigger stalls this host" was drawn from an implementation per-waypoint software trigger, which worked on the rig but existed to serve a quality gate that no longer
that triggered only `cameras[0]`, never set `TriggerSource`/`TriggerMode` at all, fired a blind 4×400 ms does. Free-running is what keeps the camera's own auto-exposure converged, and the frames it discards
burst, and ran *before* the binning fix — i.e. it was pushing 61 MB frames down the path that stalls for between waypoints cost nothing the scan was going to use.
unrelated reasons. One frame per waypoint is a **lower** USB load than a 1 fps stream. `mode = freerun`
restores the old paced-stream behaviour if a host ever does misbehave under trigger.
**Status (rig-tested 2026-08-07).** The `[Camera]`/`[Capture]` schema, in-session imaging config, Note the bandwidth ceiling this implies: at 10 Hz, RGB8 and 2×2 binning, the stream is ~153 MB/s — safely
near-lossless JPEG XL, the quality gate and unit tests are in place. On the LattePanda with the real Alvium: inside the 250 MB/s limit, but full resolution at that rate is not reachable at all (61 MB/frame). See
*Bandwidth* in [architecture.md](architecture.md#image-capture).
**Confirmed working** — software trigger sustains a full sweep (15/15 waypoints, **0 dropped, 0 stalls, 0 **Status (rig-tested 2026-08-07, before the mode rework).** The measurements below were taken with the
acquisition failures**, across four runs), which settles the load-bearing assumption behind `mode = trigger`. older software-trigger path; they bound **frame size and pacing**, which the change does not alter.
Camera configuration was verified *independently* through Allied Vision's own `vmbpy` (`RGB8`, binning 2,
2256×2256, `ExposureAuto=Off`, `GainAuto=Off`, `TriggerMode=On`, `TriggerSource=Software`,
`TriggerSelector=FrameStart`), and `ExposureTime` writes land within quantisation (`want 20000 →
readback 19980.6`). Real-motor integration, per-angle store persistence + warm start, and clean shutdown
(camera not left wedged) all verified.
**Not confirmed** — the lens was covered for the whole test, so every frame was black (mean ~1/255) and the **Confirmed working** — a full sweep at 15/15 waypoints with **0 dropped, 0 stalls, 0 acquisition
gate could only ever report `saturated`. Everything that depends on seeing a real scene is still open: see failures**, across four runs. Camera configuration was verified *independently* through Allied Vision's own
**#15–#18** in *Open / needs hardware confirmation* above, plus **#19** for the USB preconditions. `vmbpy` (`RGB8`, binning 2, 2256×2256), and `ExposureTime` writes land within quantisation (`want 20000 →
readback 19980.6`) — which is also the evidence that feature writes reach the device, the mechanism every
mode file now relies on. Real-motor integration and clean shutdown (camera not left wedged) verified.
**Not confirmed** — the lens was covered for the whole test, so every frame was black (mean ~1/255).
Everything that depends on seeing a real scene is still open: see **#15–#16** in *Open / needs hardware
confirmation* above, plus **#19** for the USB preconditions.
A useful diagnostic for the "is it the optics or the code?" question: with frame status validated and gain 0, A useful diagnostic for the "is it the optics or the code?" question: with frame status validated and gain 0,
a **2-second** exposure produced mean 3.6/255 (brightest pixel 80). A lit room at gain 0 should be well a **2-second** exposure produced mean 3.6/255 (brightest pixel 80). A lit room at gain 0 should be well
@ -109,21 +107,17 @@ scripts for this are worth keeping; note that wrapping them in `timeout` SIGTERM
## Possible follow-ups (not done) ## Possible follow-ups (not done)
- **Spurious `could not set AcquisitionFrameRateEnable=false` warning** on every camera start. - **A mode file cannot be reloaded without a restart.** `set camera mode <name>` re-applies a mode from the
`AcquisitionFrameRateEnable` and `TriggerMode` lock each other: while `TriggerMode=On` the frame-rate copy loaded at start-up, so editing a `.ini` while the program runs has no effect. Tuning on the rig
control is read-only, and vice versa. Re-configuring an already-triggered camera therefore tries to write a therefore costs a restart per edit. Re-reading the file on switch would fix it, at the price of a failure
locked feature that is *already* at the wanted value. All four state transitions were traced and the mode (a mid-session syntax error) that start-up loading currently rules out.
ordering in `configureCamera` is correct — this is cosmetic — but it is misleading noise that would mask a - **Nothing checks that a mode actually took.** Features are written and warned about individually, but the
genuine failure of that constraint. Fix by skipping the write when the value already matches. camera is never read back to confirm the resulting state, and a mode whose every write failed still logs
- **Per-angle white balance.** WB is deliberately left on the camera (`BalanceWhiteAuto=Continuous`) since it as applied. A post-apply readback of a handful of key features (`AcquisitionFrameRate`, `Width`,
is correctable in post; storing and seeding it per angle like exposure is the natural next step once `PixelFormat`) would turn a silent misconfiguration into an error.
exposure gating is proven on a lit scene.
- **Decaying sharpness reference.** `ExposureStore` keeps the *last accepted* sharpness as the blur
reference. If a scene changes character (fog rolling in), that baseline is stale in the wrong direction. A
recent-window or decaying value would be more robust — worth revisiting with real data from #16.
- Graceful shutdown on SIGINT (currently exit via `exit`/Ctrl-D; a pending `getline` can delay shutdown). - Graceful shutdown on SIGINT (currently exit via `exit`/Ctrl-D; a pending `getline` can delay shutdown).
- Make the camera index→label map fully config-driven. (JPEG XL distance/effort and the on-camera imaging - Make the camera index→label map fully config-driven. (JPEG XL distance/effort live under `[Camera]`, and
settings are now config-driven under `[Camera]` — see [configuration.md](configuration.md).) every on-camera setting lives in a mode file — see [configuration.md](configuration.md).)
- Reintroduce optional image upload to the ground station, config-driven (the old hardcoded NFS/SMB upload was - Reintroduce optional image upload to the ground station, config-driven (the old hardcoded NFS/SMB upload was
removed). removed).
- **`gimbal calib` persistence**: the fitted `counts_per_deg`/`zero_count` are always applied to the live - **`gimbal calib` persistence**: the fitted `counts_per_deg`/`zero_count` are always applied to the live

View File

@ -19,11 +19,8 @@ Per-file reference for the refactored tree, plus the shared data structures.
| [include/fgc/Calibration.h](../include/fgc/Calibration.h), [src/core/Calibration.cpp](../src/core/Calibration.cpp) | `linearFit` (least-squares, R²) + `circularMeanDeg` for the IMU-referenced calibration | | [include/fgc/Calibration.h](../include/fgc/Calibration.h), [src/core/Calibration.cpp](../src/core/Calibration.cpp) | `linearFit` (least-squares, R²) + `circularMeanDeg` for the IMU-referenced calibration |
| [include/fgc/CalibrationRoutine.h](../include/fgc/CalibrationRoutine.h), [src/core/CalibrationRoutine.cpp](../src/core/CalibrationRoutine.cpp) | `gimbal calib` worker thread: home-if-needed → pitch sweep at the first yaw position → pitch 0° → switch IMU to a no-mag XKF profile → IMU no-rotation + heading reset → yaw sweep, fitting degrees↔counts; tunable via `CalibParams`; exposes `progress()`/`report()`/`takeResult()` | | [include/fgc/CalibrationRoutine.h](../include/fgc/CalibrationRoutine.h), [src/core/CalibrationRoutine.cpp](../src/core/CalibrationRoutine.cpp) | `gimbal calib` worker thread: home-if-needed → pitch sweep at the first yaw position → pitch 0° → switch IMU to a no-mag XKF profile → IMU no-rotation + heading reset → yaw sweep, fitting degrees↔counts; tunable via `CalibParams`; exposes `progress()`/`report()`/`takeResult()` |
| [include/fgc/MtiProtocol.h](../include/fgc/MtiProtocol.h), [src/core/MtiProtocol.cpp](../src/core/MtiProtocol.cpp) | Xsens MTi binary protocol: `MtiFramer` (checksum framing), config-message builders, `parseMTData` → `ImuSample` (temp/acc/gyr/mag/euler), config-readback query builders + `applyImuConfigAck`/`finalizeImuConfig` → `ImuDeviceConfig` (product/firmware/device-id/output mode+settings/sample rate/**XKF scenario**); orientation-control builders `msgSetNoRotation` (gyro-bias update) + `msgResetOrientation` (heading reset / store) + `msgSetFilterProfile` (select XKF profile) + `pickNoMagProfile` (choose a magnetometer-free profile from the available list) | | [include/fgc/MtiProtocol.h](../include/fgc/MtiProtocol.h), [src/core/MtiProtocol.cpp](../src/core/MtiProtocol.cpp) | Xsens MTi binary protocol: `MtiFramer` (checksum framing), config-message builders, `parseMTData` → `ImuSample` (temp/acc/gyr/mag/euler), config-readback query builders + `applyImuConfigAck`/`finalizeImuConfig` → `ImuDeviceConfig` (product/firmware/device-id/output mode+settings/sample rate/**XKF scenario**); orientation-control builders `msgSetNoRotation` (gyro-bias update) + `msgResetOrientation` (heading reset / store) + `msgSetFilterProfile` (select XKF profile) + `pickNoMagProfile` (choose a magnetometer-free profile from the available list) |
| [include/fgc/ImageQuality.h](../include/fgc/ImageQuality.h), [src/core/ImageQuality.cpp](../src/core/ImageQuality.cpp) | `analyzeFrame` → `ImageMetrics` (mean luma, clipped/dark fraction, variance-of-Laplacian sharpness) over a raw frame buffer. No OpenCV, so it lives in `fgc_core` and is testable on synthetic buffers |
| [include/fgc/ExposurePolicy.h](../include/fgc/ExposurePolicy.h), [src/core/ExposurePolicy.cpp](../src/core/ExposurePolicy.cpp) | The exposure/gain control law: `evaluate` → `Verdict` (accept + reason + corrected settings), `score` for ranking attempts. Clipping outranks the mean; exposure before gain when brightening, gain before exposure when darkening; damped to prevent oscillation |
| [include/fgc/ExposureStore.h](../include/fgc/ExposureStore.h), [src/core/ExposureStore.cpp](../src/core/ExposureStore.cpp) | Per-angle exposure memory (CSV), keyed by quantised yaw/pitch. Three-tier `seed()` (fresh angle entry → last accepted anywhere → defaults) + `referenceSharpness()` for the relative blur check |
| [include/fgc/GatedCameraSource.h](../include/fgc/GatedCameraSource.h), [src/core/GatedCameraSource.cpp](../src/core/GatedCameraSource.cpp) | `ICameraSource` **decorator** turning one `trigger()` into shoot → judge → correct → reshoot, seeded from the store; keeps the best attempt and flags it `degraded` when the budget runs out. Depends only on the interface + pure modules, so the whole loop is testable against a fake camera |
| [include/fgc/sensors/Sht41Protocol.h](../include/fgc/sensors/Sht41Protocol.h), [src/sensors/Sht41Protocol.cpp](../src/sensors/Sht41Protocol.cpp) | Sensirion SHT4x wire protocol, pure: command bytes, `sht41Crc8` (poly 0x31/init 0xFF), `decodeSht41Measurement` (6-byte response → `Sht41Reading`, CRC-checked, raw→°C/%RH with RH clamped 0..100) | | [include/fgc/sensors/Sht41Protocol.h](../include/fgc/sensors/Sht41Protocol.h), [src/sensors/Sht41Protocol.cpp](../src/sensors/Sht41Protocol.cpp) | Sensirion SHT4x wire protocol, pure: command bytes, `sht41Crc8` (poly 0x31/init 0xFF), `decodeSht41Measurement` (6-byte response → `Sht41Reading`, CRC-checked, raw→°C/%RH with RH clamped 0..100) |
| [include/fgc/CameraMode.h](../include/fgc/CameraMode.h), [src/core/CameraMode.cpp](../src/core/CameraMode.cpp) | `loadCameraMode` / `loadCameraModes`: one INI per camera mode → `CameraMode` (settle timing + an **ordered, duplicate-preserving** list of Vimba X features). Order and repeats are load-bearing (GenICam selectors are stateful), so it uses its own appending `inih` handler rather than the flattening one in `Config.cpp` |
| [include/fgc/CaptureScheduler.h](../include/fgc/CaptureScheduler.h), [src/core/CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) | Capture state machine over the interfaces; injectable clock; `setGeometry` adopts a recalibration | | [include/fgc/CaptureScheduler.h](../include/fgc/CaptureScheduler.h), [src/core/CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) | Capture state machine over the interfaces; injectable clock; `setGeometry` adopts a recalibration |
| [include/fgc/Application.h](../include/fgc/Application.h), [src/core/Application.cpp](../src/core/Application.cpp) | Factory (real vs mock, headless vs TUI), wiring, control loop, `gimbal …` commands, background-result polling, `buildSnapshot()` | | [include/fgc/Application.h](../include/fgc/Application.h), [src/core/Application.cpp](../src/core/Application.cpp) | Factory (real vs mock, headless vs TUI), wiring, control loop, `gimbal …` commands, background-result polling, `buildSnapshot()` |
| [include/fgc/ui/UiSnapshot.h](../include/fgc/ui/UiSnapshot.h), [src/ui/UiSnapshot.cpp](../src/ui/UiSnapshot.cpp) | Plain-data view model (incl. `ImuView`, `EnvView`, `ActivityView`, `CalibResultView`, `DumpView`) + pure formatting helpers (state label/colour, degrees, time-ago, pending-sensors) | | [include/fgc/ui/UiSnapshot.h](../include/fgc/ui/UiSnapshot.h), [src/ui/UiSnapshot.cpp](../src/ui/UiSnapshot.cpp) | Plain-data view model (incl. `ImuView`, `EnvView`, `ActivityView`, `CalibResultView`, `DumpView`) + pure formatting helpers (state label/colour, degrees, time-ago, pending-sensors) |
@ -51,7 +48,7 @@ Per-file reference for the refactored tree, plus the shared data structures.
| [src/sensors/Sht41EnvSensor.cpp](../src/sensors/Sht41EnvSensor.cpp) | `IEnvSensor` over I2C (pImpl): soft-reset, poll thread at `[Env] period_ms`, decode via `Sht41Protocol`, staleness = no valid sample within 2× the period | always | | [src/sensors/Sht41EnvSensor.cpp](../src/sensors/Sht41EnvSensor.cpp) | `IEnvSensor` over I2C (pImpl): soft-reset, poll thread at `[Env] period_ms`, decode via `Sht41Protocol`, staleness = no valid sample within 2× the period | always |
| [src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp) | `IControlChannel` over Eclipse Paho | `WITH_MQTT` | | [src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp) | `IControlChannel` over Eclipse Paho | `WITH_MQTT` |
| [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp) | `IUserInterface` over FTXUI: panels, key bar, log pane | `WITH_TUI` | | [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp) | `IUserInterface` over FTXUI: panels, key bar, log pane | `WITH_TUI` |
| [src/camera/VimbaCameraSource.cpp](../src/camera/VimbaCameraSource.cpp) | `ICameraSource` over Vimba X (pImpl) | `WITH_VIMBA` | | [src/camera/VimbaCameraSource.cpp](../src/camera/VimbaCameraSource.cpp) | `ICameraSource` over Vimba X (pImpl): free-running stream + keep-latest, restart-on-stall, and a type-dispatched feature applier that writes a `CameraMode` verbatim | `WITH_VIMBA` |
| [src/camera/JpegXlEncoder.cpp](../src/camera/JpegXlEncoder.cpp) | libjxl encode-to-file | always | | [src/camera/JpegXlEncoder.cpp](../src/camera/JpegXlEncoder.cpp) | libjxl encode-to-file | always |
| [src/camera/ImagePipeline.cpp](../src/camera/ImagePipeline.cpp) | frame → rotate → encode → write → CamEvent (worker thread) | always | | [src/camera/ImagePipeline.cpp](../src/camera/ImagePipeline.cpp) | frame → rotate → encode → write → CamEvent (worker thread) | always |
@ -86,9 +83,8 @@ firmware `ST Y:...[ P:...]` line; degrees are derived via `Geometry`.
`*_available` flag. `*_available` flag.
### `CamEvent` ([IControlChannel.h](../include/fgc/IControlChannel.h)) ### `CamEvent` ([IControlChannel.h](../include/fgc/IControlChannel.h))
`tower`, `camera` (RGB/ACR/NIR), `heading_decideg` (yaw×10), `pitch_decideg` (pitch×10), `timestamp_ms`, `tower`, `camera` (RGB/ACR/NIR), `heading_decideg` (yaw×10), `pitch_decideg` (pitch×10) and
and `degraded` (the quality gate exhausted its attempts and kept its best effort). `timestamp_ms` (which matches the image filename). Serialized to the CamEvent JSON payload (see [mqtt-api.md](mqtt-api.md)).
Serialized to the CamEvent JSON payload (see [mqtt-api.md](mqtt-api.md)).
### `EnvEvent` ([IControlChannel.h](../include/fgc/IControlChannel.h)) ### `EnvEvent` ([IControlChannel.h](../include/fgc/IControlChannel.h))
`tower`, `temp_c`, `humidity_pct`, `timestamp_ms`. Published once per **fresh** sample (deduplicated `tower`, `temp_c`, `humidity_pct`, `timestamp_ms`. Published once per **fresh** sample (deduplicated
@ -101,8 +97,7 @@ within 2× the configured poll period, so a silently dead sensor reads as absent
value. Surfaced as `EnvView` in the TUI (`i` view) and as `SensorsView::env` in the compact panel. value. Surfaced as `EnvView` in the TUI (`i` view) and as `SensorsView::env` in the compact panel.
### `Frame` ([ICameraSource.h](../include/fgc/ICameraSource.h)) ### `Frame` ([ICameraSource.h](../include/fgc/ICameraSource.h))
Owned pixel buffer + `width`, `height`, `channels` (1 or 3), `timestamp_ms`, `cam_id`, and `degraded` Owned pixel buffer + `width`, `height`, `channels` (1 or 3), `timestamp_ms`, `cam_id`.
(carried through to the `CamEvent`).
### `ImuSample` ([MtiProtocol.h](../include/fgc/MtiProtocol.h)) ### `ImuSample` ([MtiProtocol.h](../include/fgc/MtiProtocol.h))
One decoded Xsens MTi reading: `temp_c` (°C), `acc[3]` (m/s², incl. gravity), `gyr[3]` (rad/s), One decoded Xsens MTi reading: `temp_c` (°C), `acc[3]` (m/s², incl. gravity), `gyr[3]` (rad/s),

View File

@ -59,7 +59,7 @@ When a ControlCode message arrives, the program echoes the current code back on
Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`: Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
```json ```json
{ "fwt":"ExampleTower", "cam":"RGB", "hdg":1373, "pit":300, "time":1719312345678, "degraded":false } { "fwt":"ExampleTower", "cam":"RGB", "hdg":1373, "pit":300, "time":1719312345678 }
``` ```
| Field | Type | Meaning | | Field | Type | Meaning |
@ -69,7 +69,6 @@ Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
| `hdg` | int | Gimbal yaw heading **× 10** (one decimal place encoded as integer) at capture time | | `hdg` | int | Gimbal yaw heading **× 10** (one decimal place encoded as integer) at capture time |
| `pit` | int | Gimbal pitch elevation **× 10** at capture time (derived from the pitch encoder) | | `pit` | int | Gimbal pitch elevation **× 10** at capture time (derived from the pitch encoder) |
| `time` | int | Capture timestamp, Unix epoch **milliseconds** (matches the `.jxl` filename) | | `time` | int | Capture timestamp, Unix epoch **milliseconds** (matches the `.jxl` filename) |
| `degraded` | bool | `true` when the quality gate ran out of attempts and this is its best effort rather than a frame that passed — see `[Capture]` in [configuration.md](configuration.md). Consumers should weight or skip these rather than treating them as good |
> The `time` value is the same Unix-ms timestamp used as the image filename, so a consumer can locate the file > The `time` value is the same Unix-ms timestamp used as the image filename, so a consumer can locate the file
> for a given event: `<RGB|ACR|NIR>/<time>.jxl`. > for a given event: `<RGB|ACR|NIR>/<time>.jxl`.

View File

@ -28,15 +28,16 @@ Keep entries dated; move shipped items into the reference docs.
re-engineer retained "last value" semantics). The abstraction seam already re-engineer retained "last value" semantics). The abstraction seam already
exists (`IControlChannel`). _(TODO: confirm approach, target release.)_ exists (`IControlChannel`). _(TODO: confirm approach, target release.)_
- **RGB camera.** _Largely landed._ The Alvium 1800 U-2040c runs end-to-end on the - **RGB camera.** _Largely landed._ The Alvium 1800 U-2040c runs end-to-end on the
rig (software-triggered acquire → quality gate → JXL → `CamEvent`), so the rig (free-running acquire → settle wait → JXL → `CamEvent`), so the
`camera/rgb` self-test is no longer blocked on integration. What remains is `camera/rgb` self-test is no longer blocked on integration. What remains is
tuning against a real scene — see [known-issues.md](known-issues.md) #15–#18 — confirming the shipped `standard`/`night` modes against a real scene, and that
and the capture logic itself is described in 10 Hz sustains with zero dropped frames on the LattePanda. The capture logic is
[architecture.md](architecture.md#image-capture). described in [architecture.md](architecture.md#image-capture) and the modes in
- **Per-angle white balance.** Exposure and gain are already remembered per [configuration.md](configuration.md).
heading; WB is still left to the camera's continuous auto. Extending the store - **A dusk/dawn mode, and automatic switching.** `standard` and `night` are
to WB ratios is the natural follow-on once exposure gating is proven on a lit chosen by hand (`set camera mode <name>`). A third mode for the transition, and
scene. _(TODO: confirm it is worth the extra state.)_ a rule to pick between them — by clock, sun angle, or measured scene brightness
— is the obvious follow-on. _(TODO: decide the trigger.)_
- **Thermal camera.** Add the thermal sensor with radiometric handling (NUC, - **Thermal camera.** Add the thermal sensor with radiometric handling (NUC,
temperature range); enables the `camera/thermal` self-test. _(TODO: model/SDK, temperature range); enables the `camera/thermal` self-test. _(TODO: model/SDK,
calibration workflow.)_ calibration workflow.)_

View File

@ -50,18 +50,17 @@ active (MQTT today, RabbitMQ once integrated — see the MQTT→RabbitMQ migrati
One leaf per physical sensor; shares a common frame-quality core. Uses the existing One leaf per physical sensor; shares a common frame-quality core. Uses the existing
`ICameraSource`/`ImagePipeline`. `ICameraSource`/`ImagePipeline`.
**The shared frame-quality core already exists.** `analyzeFrame()` in **The frame-quality helpers this leaf needs no longer exist.** `ImageQuality`/`ExposurePolicy` were
[ImageQuality.h](../include/fgc/ImageQuality.h) computes mean luma, clipped/dark fraction and a removed with the capture quality gate (the camera's own auto-exposure does that job now), so the
variance-of-Laplacian sharpness score over a raw buffer, with no OpenCV dependency, and is unit-tested histogram and sharpness checks below have to be written for the self-test itself. That is the right home
against synthetic frames. The `camera / rgb` leaf should call it rather than reimplementing the histogram for them: a self-test measures and reports, where the gate tried to *act* on the same numbers in the
and sharpness checks below — it already covers "usable histogram (not clipped)" and "sharpness/focus capture path. Still missing either way: dead/hot-pixel counting, realized-FPS measurement, timestamp
metric". What it does **not** yet provide is dead/hot-pixel counting, realized-FPS measurement, timestamp monotonicity and encode-throughput timing.
monotonicity or encode-throughput timing.
The RGB camera itself is integrated and verified on the rig (see The RGB camera itself is integrated and verified on the rig (see
[architecture.md](architecture.md#image-capture)), so this leaf is now an implementation task rather than a [architecture.md](architecture.md#image-capture)), so this leaf is an implementation task rather than a
blocked one. Note that a self-test asserting exposure health needs the lens uncovered and the limits in blocked one. Note that a self-test asserting exposure health needs the lens uncovered and the shipped
`[Camera]` tuned first — [known-issues.md](known-issues.md) #15–#18. camera modes tuned first — [known-issues.md](known-issues.md) #15–#16.
- **`camera / rgb`** — _Healthy:_ enumerates and connects; delivers frames at ~the - **`camera / rgb`** — _Healthy:_ enumerates and connects; delivers frames at ~the
configured rate; exposure/gain give a usable histogram (not clipped); few configured rate; exposure/gain give a usable histogram (not clipped); few

42
include/fgc/CameraMode.h Normal file
View File

@ -0,0 +1,42 @@
#pragma once
#include <map>
#include <string>
#include <utility>
#include <vector>
namespace fgc {
// One named camera mode, loaded from <mode_dir>/<name>.ini.
//
// A mode is deliberately NOT a struct of typed camera settings: it is the raw
// list of Vimba X features to write, in file order, plus the few values the
// program itself needs. That means a new camera feature can be set by adding a
// line to an INI file - no C++ change, no vocabulary to keep in sync with the
// SDK. VimbaCameraSource asks the camera what type each feature is and converts
// the string accordingly.
struct CameraMode {
std::string name; // filename stem
std::string description;
int settle_wait_ms = 1000; // pause after standstill before keeping a frame
int frame_timeout_ms = 2000; // give up on the waypoint after this
// Vimba X features in FILE ORDER, duplicates preserved. Both matter:
// GenICam selectors are stateful, so "BalanceRatioSelector=Red,
// BalanceRatio=2.87, BalanceRatioSelector=Blue, BalanceRatio=2.0" is the
// only way to write both ratios. A std::map would sort and de-duplicate
// that into nonsense.
std::vector<std::pair<std::string, std::string>> features;
};
// Load one mode file. `name` comes from the filename stem.
// Throws std::runtime_error with a clear message on a missing file, an unknown
// section, or an unknown/invalid [Mode] key.
CameraMode loadCameraMode(const std::string& path);
// Load every *.ini in `dir`, keyed by mode name. Throws if the directory is
// missing or contains no modes - a malformed mode nobody is using today still
// fails at startup rather than the first time someone switches to it.
std::map<std::string, CameraMode> loadCameraModes(const std::string& dir);
} // namespace fgc

View File

@ -18,13 +18,12 @@ namespace fgc {
// 1. polls the control channel (updates control code / target heading, // 1. polls the control channel (updates control code / target heading,
// echoes the code back as status), // echoes the code back as status),
// 2. reads motor telemetry (per-axis encoder counts + state), // 2. reads motor telemetry (per-axis encoder counts + state),
// 3. runs the capture cycle as a move -> settle -> trigger machine: when the // 3. runs the capture cycle as a move -> settle -> wait -> keep machine: when
// interval elapses it issues an absolute `MOVE <yaw>,<pitch>` to the next // the interval elapses it issues an absolute `MOVE <yaw>,<pitch>` to the next
// target, waits until both axes report standstill at that target, then asks // target, waits until both axes report standstill at that target, waits a
// the camera for an image. Whether that is a single triggered frame, and // further settle_wait_ms so the free-running camera has exposed a frame with
// whether it is judged and re-shot until it passes, is decided below this // the gimbal at rest, then keeps the newest frame. If none is ready it
// layer (see GatedCameraSource) - the scheduler only cares that trigger() // retries each tick, and gives up on the waypoint after frame_timeout_ms.
// eventually succeeds, and retries next tick if it does not.
// //
// ControlCode 0 = automatic sweep through the ScanGrid (front to back, repeating); // ControlCode 0 = automatic sweep through the ScanGrid (front to back, repeating);
// ControlCode 1 = drive yaw to the MQTT-supplied target heading (pitch held). // ControlCode 1 = drive yaw to the MQTT-supplied target heading (pitch held).
@ -43,6 +42,12 @@ public:
void setImageRate(double rate); // images per second void setImageRate(double rate); // images per second
double imageRate() const { return image_rate_; } double imageRate() const { return image_rate_; }
// Per-camera-mode timing (see CameraMode): how long to wait after standstill
// before keeping a frame, and how long to keep retrying before abandoning the
// waypoint. Called on the control thread, same as tick().
void setSettleWait(int settle_ms, int timeout_ms);
int settleWaitMs() const { return settle_wait_ms_; }
// Replace the degrees<->counts maps (e.g. after `gimbal calib`) so subsequent // Replace the degrees<->counts maps (e.g. after `gimbal calib`) so subsequent
// scan waypoints target with the calibrated conversion. Called on the control // scan waypoints target with the calibrated conversion. Called on the control
// thread, same as tick(), so no locking is needed. // thread, same as tick(), so no locking is needed.
@ -79,6 +84,9 @@ private:
std::string target_heading_ = "0"; std::string target_heading_ = "0";
bool moving_ = false; // a MOVE has been issued, awaiting settle bool moving_ = false; // a MOVE has been issued, awaiting settle
long long settled_at_ms_ = 0; // when standstill was first seen (0 = not yet)
int settle_wait_ms_ = 1000;
int frame_timeout_ms_ = 2000;
bool pitch_present_ = false; // a P: segment was seen in telemetry (2-axis rig) bool pitch_present_ = false; // a P: segment was seen in telemetry (2-axis rig)
long yaw_target_ = 0; // current target, encoder counts long yaw_target_ = 0; // current target, encoder counts
long pitch_target_ = 0; long pitch_target_ = 0;

View File

@ -33,23 +33,15 @@ struct CameraConfig {
std::vector<std::string> ids; // GigE IP or USB DEV_ id, in order std::vector<std::string> ids; // GigE IP or USB DEV_ id, in order
std::vector<std::string> labels = {"RGB", "ACR", "NIR"}; // index -> output subfolder std::vector<std::string> labels = {"RGB", "ACR", "NIR"}; // index -> output subfolder
// On-camera imaging (Alvium), applied in-session by VimbaCameraSource at start(). // On-camera imaging settings live in mode files, one per mode: a mode named X
// Defaults keep frames small enough to transfer reliably on the LattePanda USB3 host // is <mode_dir>/X.ini (see CameraMode.h). Nothing about the sensor is
// (large ~60 MB frames stall; see docs/known-issues.md "Camera acquisition"). // configured here - a mode file is the whole story, so the two can never
int binning = 2; // 1 = full res; 2 = 2x2 (~5 MP, ~15 MB) default // disagree about, say, binning.
int offset_x = 0; // ROI origin; 0/0 + 0 size => sensor maximum (full frame) // Relative => resolved against the DIRECTORY HOLDING config.ini. The deployed
int offset_y = 0; // config.ini lives at the repo root (deploy.sh copies the example there), so
int width = 0; // ROI width in pixels; 0 => sensor maximum // the default is written from there, not from config/.
int height = 0; // ROI height in pixels; 0 => sensor maximum std::string mode_dir = "config/camera_modes/rgb";
std::string pixel_format = "RGB8"; // on-camera de-Bayer + white balance -> colour out std::string mode = "standard"; // which mode to start in
int throughput_mbytes = 250; // DeviceLinkThroughputLimit (MByte/s); do NOT max it
double stream_fps = 1.0; // paced acquisition rate; keeps auto converged. Keep low:
// ~2 fps of 15 MB frames stalls this USB3 host; 1 fps sustains
bool exposure_auto = true; // ExposureAuto = Continuous (adapt to changing light)
double exposure_max_us = 0.0; // ExposureAutoMax cap (us); 0 => leave camera default
bool gain_auto = true; // GainAuto = Continuous
double gain_max_db = 0.0; // GainAutoMax cap (dB); 0 => leave camera default
bool white_balance_auto = true; // BalanceWhiteAuto = Continuous
// JPEG XL encoding (consumed by ImagePipeline). Near-lossless default. // JPEG XL encoding (consumed by ImagePipeline). Near-lossless default.
double jxl_distance = 0.8; // 0 = lossless; higher = lossier double jxl_distance = 0.8; // 0 = lossless; higher = lossier
@ -58,47 +50,6 @@ struct CameraConfig {
int rotation_deg = 0; int rotation_deg = 0;
}; };
// [Capture]: how a frame is acquired at each waypoint, and the quality bar it has
// to clear. See docs/configuration.md.
struct CaptureConfig {
// "trigger" = one deliberate software-triggered frame per attempt (default).
// "freerun" = the older paced stream + keep-latest, kept as a fallback in case
// software trigger misbehaves on a given host.
std::string mode = "trigger";
// Judge each frame and reshoot with corrected exposure until it passes. With
// this off, `mode` still applies but the first frame is always kept.
bool quality_gate = true;
int max_attempts = 3;
int min_attempts = 1; // >1 always shoots extra and keeps the sharpest
int acquire_timeout_ms = 2000;
int settle_delay_ms = 150; // pause after standstill before the first shot
// Exposure targets (see ExposurePolicy).
double target_mean = 110.0;
double mean_tolerance = 12.0;
double clip_max_fraction = 0.005;
double exposure_min_us = 50.0;
double damping = 0.8;
// Starting point when the store has nothing to offer.
double default_exposure_us = 5000.0;
double default_gain_db = 0.0;
// Blur is judged relative to the same angle's own history; see ExposurePolicy.
double blur_relative_floor = 0.5;
double blur_absolute_floor = 0.0; // 0 disables the absolute backstop
// Metric cost knobs.
int metric_stride = 4;
int sharpness_roi_px = 512;
// Per-angle exposure memory. Empty path => resolved to a default beside the
// logs at load time.
std::string exposure_store;
double angle_quantum_deg = 1.0;
long long store_stale_s = 1800;
};
struct PathsConfig { struct PathsConfig {
// Where captured .jxl images are written. Supports leading ~ and $ENV // Where captured .jxl images are written. Supports leading ~ and $ENV
// expansion. Empty => resolved to a sensible default at load time. // expansion. Empty => resolved to a sensible default at load time.
@ -187,7 +138,6 @@ struct AppConfig {
NetworkConfig network; NetworkConfig network;
SerialConfig serial; SerialConfig serial;
CameraConfig camera; CameraConfig camera;
CaptureConfig capture; // [Capture] acquisition mode + image quality gate
PathsConfig paths; PathsConfig paths;
FeaturesConfig features; FeaturesConfig features;
LoggingConfig logging; LoggingConfig logging;

View File

@ -1,51 +0,0 @@
#pragma once
#include "fgc/ImageQuality.h"
#include <string>
namespace fgc {
// The exposure/gain control law: given what an image turned out like, decide
// whether to keep it and what to change if not. Pure (no camera, no I/O, no
// clock) so the whole correction strategy is unit-testable.
struct CaptureSettings {
double exposure_us = 0.0;
double gain_db = 0.0;
};
struct PolicyParams {
double target_mean = 110.0; // desired mean luma, 0..255
double mean_tolerance = 12.0; // accept within +-this of the target
double clip_max_fraction = 0.005; // 0.5% blown pixels is the ceiling
double exposure_min_us = 50.0;
double exposure_max_us = 20000.0; // doubles as the motion-blur budget
double gain_max_db = 12.0;
double damping = 0.8; // <1 damps the correction, preventing oscillation
// Blur is judged RELATIVE to the same angle's own history: a foggy horizon is
// legitimately low-detail, so an absolute threshold would retry forever on it.
double blur_relative_floor = 0.5; // retry below this fraction of the reference
double blur_absolute_floor = 0.0; // 0 disables the catastrophic-blur backstop
};
struct Verdict {
bool accept = false;
std::string reason; // "ok" / "clipped" / "dark" / "bright" / "blur" / "saturated"
CaptureSettings next; // settings to use for the next attempt (== current if accepting)
};
// Judge one attempt. `reference_sharpness` is this angle's recent sharpness; pass
// 0 when unknown, which skips the relative blur check.
//
// Precedence matters: clipping outranks the mean, because a bright sky can blow
// out while the average still looks perfectly reasonable - and blown highlights
// are unrecoverable, whereas a slightly dark frame is not.
Verdict evaluate(const ImageMetrics& m, const CaptureSettings& current, const PolicyParams& p,
double reference_sharpness = 0.0);
// Rank attempts so "best so far" is well defined: sharpness discounted by how far
// the exposure strayed. Higher is better.
double score(const ImageMetrics& m, const PolicyParams& p);
} // namespace fgc

View File

@ -1,78 +0,0 @@
#pragma once
#include "fgc/ExposurePolicy.h"
#include <map>
#include <optional>
#include <string>
namespace fgc {
// Per-angle exposure memory: what settings worked last time the gimbal looked
// this way. This is what lets a triggered capture start from a setting known to
// be roughly right, instead of paying the camera's own auto-exposure several
// frames to re-converge at every waypoint.
//
// Keyed by QUANTISED yaw/pitch rather than waypoint index, so the memory survives
// an edit to the scan grid and also serves ControlCode 1 directed moves, which
// have no waypoint index at all.
struct StoreEntry {
double yaw_deg = 0.0;
double pitch_deg = 0.0;
double exposure_us = 0.0;
double gain_db = 0.0;
double sharpness = 0.0; // sharpness of the last accepted frame here
double mean_luma = 0.0;
long long timestamp_ms = 0; // when this entry was last updated (epoch ms)
int attempts = 0; // attempts the last visit needed
};
class ExposureStore {
public:
// quantum_deg: angle bucket size. stale_s: how old an entry may be and still
// be trusted as this angle's seed.
explicit ExposureStore(std::string path, double quantum_deg = 1.0, long long stale_s = 1800);
// Load from / save to the CSV. Both are tolerant: a missing file is simply an
// empty store, and a malformed row is skipped rather than failing the load, so
// a corrupt line can never stop a scan from running.
bool load();
bool save() const;
// Bucket key for an angle, e.g. "y137_p-5".
static std::string keyFor(double yaw_deg, double pitch_deg, double quantum_deg);
std::optional<StoreEntry> find(double yaw_deg, double pitch_deg) const;
std::size_t size() const { return entries_.size(); }
const std::string& path() const { return path_; }
// Three-tier seed, in order of how well each predicts the CURRENT light:
// 1. this angle's own entry, if fresher than stale_s;
// 2. else the last settings accepted anywhere - light changes affect all
// angles together, so a 30 s old reading one heading over beats an
// hour-old reading at this exact heading;
// 3. else the configured defaults.
CaptureSettings seed(double yaw_deg, double pitch_deg, long long now_ms,
const CaptureSettings& defaults) const;
// This angle's reference sharpness for the relative blur check, or 0 when
// unknown or too old to compare against.
double referenceSharpness(double yaw_deg, double pitch_deg, long long now_ms) const;
// Record an accepted capture.
void update(double yaw_deg, double pitch_deg, const CaptureSettings& settings,
const ImageMetrics& metrics, long long now_ms, int attempts);
private:
bool fresh(const StoreEntry& e, long long now_ms) const;
std::string path_;
double quantum_deg_;
long long stale_s_;
std::map<std::string, StoreEntry> entries_;
std::optional<CaptureSettings> last_accepted_;
long long last_accepted_ms_ = 0;
};
} // namespace fgc

View File

@ -1,91 +0,0 @@
#pragma once
#include "fgc/ExposurePolicy.h"
#include "fgc/ExposureStore.h"
#include "fgc/ICameraSource.h"
#include "fgc/ImageQuality.h"
#include <functional>
#include <memory>
#include <string>
namespace fgc {
// Outcome of one gated capture, for logging and the TUI.
struct CaptureReport {
bool captured = false; // a frame was delivered at all
bool degraded = false; // budget exhausted; this is the best effort
int attempts = 0;
std::string reason; // verdict of the attempt we kept
ImageMetrics metrics; // metrics of the frame we kept
CaptureSettings settings; // settings that produced it
double yaw_deg = 0.0, pitch_deg = 0.0;
};
// An ICameraSource that wraps another one and turns a single trigger() into
// "shoot, judge, correct, reshoot until acceptable", seeded from per-angle memory.
//
// It is a DECORATOR so that nothing above it changes: CaptureScheduler still just
// calls trigger(), and the accepted frame still reaches ImagePipeline through the
// ordinary frame callback. Because it depends only on ICameraSource plus the pure
// policy/metric modules, the entire retry loop is unit-testable against a fake
// camera - no hardware, no OpenCV.
class GatedCameraSource : public ICameraSource {
public:
struct Params {
bool enabled = true;
int max_attempts = 3;
int min_attempts = 1; // >1 always shoots extra and keeps the sharpest
int acquire_timeout_ms = 2000;
int settle_delay_ms = 0; // extra pause after the move, before attempt 1
CaptureSettings defaults; // used when the store has nothing
QualityParams quality;
PolicyParams policy;
};
// angle: where the gimbal is pointing now (degrees), used to key the store.
// now_ms: injectable clock, for deterministic tests.
GatedCameraSource(std::unique_ptr<ICameraSource> inner, ExposureStore* store, Params params,
std::function<std::pair<double, double>()> angle,
std::function<long long()> now_ms = {},
std::function<void(int)> sleep_ms = {});
~GatedCameraSource() override;
void open() override;
void close() override;
void start() override;
void stop() override;
bool trigger() override;
bool setFrameRate(double fps) override;
void setFrameCallback(FrameCallback cb) override;
int cameraCount() const override;
std::vector<CameraDeviceInfo> deviceInfo() override;
// Pass-throughs, so the gate can itself be wrapped or inspected.
bool acquireFrame(Frame& out, int timeout_ms) override;
bool setExposure(double us) override;
bool setGain(double db) override;
bool setAutoExposureGain(bool on) override;
double currentGain() override;
// Result of the most recent trigger(), for the log and the TUI.
const CaptureReport& lastReport() const { return last_report_; }
// How many captures this session fell back to a best effort. A rising count is
// the signal that thresholds or the exposure limits need revisiting.
long long degradedTotal() const { return degraded_total_; }
private:
std::unique_ptr<ICameraSource> inner_;
ExposureStore* store_; // not owned; may be null
Params params_;
std::function<std::pair<double, double>()> angle_;
std::function<long long()> now_ms_;
std::function<void(int)> sleep_ms_;
FrameCallback callback_;
CaptureReport last_report_;
long long degraded_total_ = 0;
};
} // namespace fgc

View File

@ -1,5 +1,7 @@
#pragma once #pragma once
#include "fgc/CameraMode.h"
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <string> #include <string>
@ -15,10 +17,6 @@ struct Frame {
int channels = 0; // 1 (mono) or 3 (RGB) int channels = 0; // 1 (mono) or 3 (RGB)
long long timestamp_ms = 0; // Unix epoch ms long long timestamp_ms = 0; // Unix epoch ms
int cam_id = 0; // index into the configured camera list int cam_id = 0; // index into the configured camera list
// Set when the quality gate ran out of attempts and emitted its best effort
// anyway. Carried through to the CamEvent so degraded frames stay identifiable
// downstream instead of being silently mixed in with good ones.
bool degraded = false;
}; };
// Live per-camera identity, sensor telemetry, and acquisition stats, for the // Live per-camera identity, sensor telemetry, and acquisition stats, for the
@ -38,8 +36,6 @@ struct CameraDeviceInfo {
// Live sensor telemetry (read from the camera, throttled). // Live sensor telemetry (read from the camera, throttled).
double exposure_us = 0.0; double exposure_us = 0.0;
double gain_db = 0.0; double gain_db = 0.0;
double wb_red = 0.0;
double wb_blue = 0.0;
double temperature_c = 0.0; double temperature_c = 0.0;
double actual_fps = 0.0; double actual_fps = 0.0;
@ -50,8 +46,7 @@ struct CameraDeviceInfo {
}; };
// Abstraction over the camera array. Implemented by VimbaCameraSource (Allied // Abstraction over the camera array. Implemented by VimbaCameraSource (Allied
// Vision Vimba X), MockCameraSource (synthetic frames, no hardware), and // Vision Vimba X) and MockCameraSource (synthetic frames, no hardware).
// GatedCameraSource (a decorator adding the image-quality gate).
// //
// Completed frames are delivered to the callback set via setFrameCallback(); // Completed frames are delivered to the callback set via setFrameCallback();
// the consumer (ImagePipeline) encodes and stores them. // the consumer (ImagePipeline) encodes and stores them.
@ -67,32 +62,14 @@ public:
virtual void stop() = 0; virtual void stop() = 0;
// Capture one image and deliver it to the frame callback. Returns true on // Capture one image and deliver it to the frame callback. Returns true on
// success. In free-run this hands over the freshest streamed frame; behind the // success. The camera free-runs, so this hands over the freshest complete
// quality gate it runs the whole acquire/judge/correct loop. // frame; it returns false when no frame newer than the last one is available
// yet, and the caller simply retries.
virtual bool trigger() = 0; virtual bool trigger() = 0;
// Optional: change the acquisition frame rate. Default no-op (e.g. mocks). // Apply a camera mode's feature list (see CameraMode.h). Default: no-op, for
virtual bool setFrameRate(double /*fps*/) { return false; } // sources with no features to set.
virtual bool setMode(const CameraMode& /*mode*/) { return false; }
// ---- Deliberate acquisition + manual exposure control ----
// Used by GatedCameraSource to shoot, measure and reshoot. Defaults are no-ops
// (following setFrameRate above) so mocks and existing tests need no changes;
// a source that returns false from acquireFrame simply cannot be gated.
// Acquire exactly ONE frame and return it here rather than via the callback,
// so the caller can judge it before deciding whether to keep it.
virtual bool acquireFrame(Frame& /*out*/, int /*timeout_ms*/) { return false; }
virtual bool setExposure(double /*microseconds*/) { return false; }
virtual bool setGain(double /*db*/) { return false; }
// Turn the camera's own continuous auto-exposure/gain on or off. The gate owns
// exposure, so it switches these off; free-run leaves them on.
virtual bool setAutoExposureGain(bool /*on*/) { return false; }
// Gain actually in effect, in dB (what the camera reports, not what we asked
// for) - the noise proxy the policy gates on.
virtual double currentGain() { return 0.0; }
virtual void setFrameCallback(FrameCallback cb) = 0; virtual void setFrameCallback(FrameCallback cb) = 0;

View File

@ -20,10 +20,6 @@ struct CamEvent {
int heading_decideg = 0; // yaw heading * 10 (one decimal as integer) int heading_decideg = 0; // yaw heading * 10 (one decimal as integer)
int pitch_decideg = 0; // pitch elevation * 10 (one decimal as integer) int pitch_decideg = 0; // pitch elevation * 10 (one decimal as integer)
long long timestamp_ms = 0; // Unix epoch ms; matches the image filename long long timestamp_ms = 0; // Unix epoch ms; matches the image filename
// True when the quality gate ran out of attempts and this is its best effort
// rather than a frame that passed. Published so consumers can weight or skip
// degraded images instead of silently treating them as good.
bool degraded = false;
}; };
// Ambient temperature/humidity reading published once per fresh sensor sample. // Ambient temperature/humidity reading published once per fresh sensor sample.

View File

@ -1,44 +0,0 @@
#pragma once
#include "fgc/ICameraSource.h"
namespace fgc {
// Image-quality metrics over a raw captured frame. Pure (no OpenCV, no I/O) so it
// lives in fgc_core and is unit-testable against synthetic buffers - mirrors
// MtiProtocol / Sht41Protocol. The app target links OpenCV, but fgc_core and the
// test target do not, and these metrics are a few dozen lines by hand.
struct QualityParams {
// Exposure/clipping stats sample every Nth pixel: 16x cheaper at stride 4 and
// statistically indistinguishable on a 5 MP natural scene. Note it CAN alias on
// strictly periodic detail whose period divides the stride (a synthetic
// checkerboard is the pathological case), biasing the mean; set stride = 1 if a
// scene ever turns out to be regular enough for that to matter.
int stride = 4;
// Sharpness is measured at FULL resolution on a centre square of this size -
// subsampling destroys exactly the high-frequency content it measures.
int sharpness_roi_px = 512;
// A pixel at or above this counts as clipped (blown highlight).
int clip_level = 254;
// A pixel at or below this counts as crushed black.
int dark_level = 1;
};
struct ImageMetrics {
bool valid = false; // false when the frame was empty/malformed
double mean_luma = 0.0; // 0..255
double clipped_fraction = 0.0; // pixels with ANY channel >= clip_level
double dark_fraction = 0.0; // pixels with ALL channels <= dark_level
double sharpness = 0.0; // variance of the Laplacian over the centre ROI
// Filled in by the caller from the camera's own Gain feature, not derived from
// pixels: the camera reports gain exactly, whereas estimating noise from a
// textured scene is unreliable.
double gain_db = 0.0;
};
// Compute the metrics for one frame. Supports 1 channel (mono) and 3 (RGB8).
// Returns `valid = false` for anything else or for an empty buffer.
ImageMetrics analyzeFrame(const Frame& frame, const QualityParams& params = {});
} // namespace fgc

View File

@ -30,10 +30,6 @@ std::string defaultOutputDir();
// ($XDG_DATA_HOME/fire_gimbal_control/logs, else ~/.local/share/...). // ($XDG_DATA_HOME/fire_gimbal_control/logs, else ~/.local/share/...).
std::string defaultLogDir(); std::string defaultLogDir();
// Default path for the per-angle exposure store
// ($XDG_DATA_HOME/fire_gimbal_control/exposure_store.csv, else ~/.local/share/...).
std::string defaultExposureStore();
// "<prefix>_YYYYMMDD-HHMMSS.log" using the local clock. // "<prefix>_YYYYMMDD-HHMMSS.log" using the local clock.
std::string timestampedLogName(const std::string& prefix); std::string timestampedLogName(const std::string& prefix);

View File

@ -1,5 +1,6 @@
#pragma once #pragma once
#include "fgc/CameraMode.h"
#include "fgc/Config.h" #include "fgc/Config.h"
#include "fgc/ICameraSource.h" #include "fgc/ICameraSource.h"
@ -8,39 +9,31 @@
namespace fgc { namespace fgc {
// Real camera source backed by the Allied Vision Vimba X SDK (VmbCPP). Opens the // Real camera source backed by the Allied Vision Vimba X SDK (VmbCPP). Opens the
// cameras in CameraConfig and applies the on-camera imaging settings in-session. // cameras named in CameraConfig; every on-camera setting comes from a CameraMode,
// which is just a list of Vimba features written verbatim.
// //
// Two acquisition modes, chosen with setAcquisitionMode() before start(): // The camera free-runs continuously at the mode's AcquisitionFrameRate with its
// TRIGGERED (default) - TriggerMode=On + TriggerSource=Software. acquireFrame() // own auto-exposure tracking the light. trigger() hands over the latest complete
// fires one TriggerSoftware and returns that frame, so the image belongs to the // frame, restarting acquisition if the stream has stalled. Encode/save lives in
// waypoint that asked for it and its exposure can be set deliberately. // ImagePipeline.
// FREE-RUN - a paced low-rate stream; trigger() hands over the latest complete
// frame, restarting acquisition if the stream has stalled. Kept as a fallback.
// Encode/save lives in ImagePipeline; quality gating lives in GatedCameraSource.
class VimbaCameraSource : public ICameraSource { class VimbaCameraSource : public ICameraSource {
public: public:
explicit VimbaCameraSource(CameraConfig config); explicit VimbaCameraSource(CameraConfig config);
~VimbaCameraSource() override; ~VimbaCameraSource() override;
// Must be called before start() to take effect (features are applied there). // Mode to apply at start(). Also the mode setMode() replaces.
void setAcquisitionMode(bool triggered, bool manual_exposure); void setInitialMode(CameraMode mode);
void open() override; void open() override;
void close() override; void close() override;
void start() override; void start() override;
void stop() override; void stop() override;
bool trigger() override; bool trigger() override;
bool setFrameRate(double fps) override; bool setMode(const CameraMode& mode) override;
void setFrameCallback(FrameCallback cb) override; void setFrameCallback(FrameCallback cb) override;
int cameraCount() const override; int cameraCount() const override;
std::vector<CameraDeviceInfo> deviceInfo() override; std::vector<CameraDeviceInfo> deviceInfo() override;
bool acquireFrame(Frame& out, int timeout_ms) override;
bool setExposure(double us) override;
bool setGain(double db) override;
bool setAutoExposureGain(bool on) override;
double currentGain() override;
private: private:
struct Impl; struct Impl;
std::unique_ptr<Impl> impl_; std::unique_ptr<Impl> impl_;

View File

@ -4,18 +4,15 @@
#include "fgc/Logger.h" #include "fgc/Logger.h"
#include <chrono> #include <chrono>
#include <cmath>
#include <string> #include <string>
namespace fgc { namespace fgc {
// Synthetic camera for development without hardware. On trigger() it generates // Synthetic camera for development without hardware. On trigger() it generates
// a small RGB gradient frame (varying with time so successive captures differ) // a small RGB gradient frame (varying with time so successive captures differ)
// and delivers it to the frame callback. // and delivers it to the frame callback. setMode() only records the mode name -
// // there is no sensor to configure - which is enough to exercise mode switching
// It also implements the deliberate-acquisition API with a crude sensor model // end to end with --mock-camera.
// (brightness proportional to exposure x gain), so the quality gate's whole
// shoot -> judge -> correct loop can be exercised end to end with --mock-camera.
class MockCameraSource : public ICameraSource { class MockCameraSource : public ICameraSource {
public: public:
explicit MockCameraSource(int count = 1, uint32_t width = 640, uint32_t height = 480) explicit MockCameraSource(int count = 1, uint32_t width = 640, uint32_t height = 480)
@ -40,31 +37,12 @@ public:
void setFrameCallback(FrameCallback cb) override { callback_ = std::move(cb); } void setFrameCallback(FrameCallback cb) override { callback_ = std::move(cb); }
int cameraCount() const override { return count_; } int cameraCount() const override { return count_; }
// ---- Deliberate acquisition, for the quality gate ---- bool setMode(const CameraMode& mode) override {
mode_ = mode.name;
bool acquireFrame(Frame& out, int /*timeout_ms*/) override { LOG_INFO << "[mock] camera mode -> " << mode_;
out = synth();
++frames_;
LOG_TRACE_CAT(LogCat::Camera)
<< "RX frame cam0 " << width_ << 'x' << height_ << " exp=" << exposure_us_
<< "us gain=" << gain_db_ << "dB (mock)";
return true; return true;
} }
bool setExposure(double us) override {
exposure_us_ = us;
return true;
}
bool setGain(double db) override {
gain_db_ = db;
return true;
}
bool setAutoExposureGain(bool on) override {
auto_on_ = on;
return true;
}
double currentGain() override { return gain_db_; }
// Synthetic device info so the expanded camera view renders (and is testable) // Synthetic device info so the expanded camera view renders (and is testable)
// without hardware. Non-hardware fields (temperature, exposure...) stay at 0. // without hardware. Non-hardware fields (temperature, exposure...) stay at 0.
std::vector<CameraDeviceInfo> deviceInfo() override { std::vector<CameraDeviceInfo> deviceInfo() override {
@ -86,9 +64,8 @@ public:
} }
private: private:
// A gradient frame whose overall brightness follows exposure x gain, so the // A mid-grey gradient frame. The pattern varies with time so successive
// quality gate sees its corrections take effect. The pattern varies with time // captures differ.
// so successive captures differ.
Frame synth() const { Frame synth() const {
Frame f; Frame f;
f.width = width_; f.width = width_;
@ -100,18 +77,13 @@ private:
.count(); .count();
f.data.resize(static_cast<size_t>(width_) * height_ * 3); f.data.resize(static_cast<size_t>(width_) * height_ * 3);
// Exposure/gain -> brightness. Calibrated so the default 5000 us at 0 dB const double level = 110.0;
// lands near the policy's default target mean, making a mock run converge
// the way a real one should.
const double lin = exposure_us_ * std::pow(10.0, gain_db_ / 20.0);
const double level = auto_on_ ? 110.0 : lin * 0.022;
const uint8_t phase = static_cast<uint8_t>(f.timestamp_ms); const uint8_t phase = static_cast<uint8_t>(f.timestamp_ms);
for (uint32_t y = 0; y < height_; ++y) { for (uint32_t y = 0; y < height_; ++y) {
for (uint32_t x = 0; x < width_; ++x) { for (uint32_t x = 0; x < width_; ++x) {
size_t i = (static_cast<size_t>(y) * width_ + x) * 3; size_t i = (static_cast<size_t>(y) * width_ + x) * 3;
// Zero-mean texture around `level` so there is real detail for the // Zero-mean texture around `level`. The cast matters:
// sharpness metric without shifting the mean. The cast matters:
// (x+y+phase)%3 is unsigned, so subtracting 1 from the zero case // (x+y+phase)%3 is unsigned, so subtracting 1 from the zero case
// would wrap to UINT_MAX and clip a third of the frame to white. // would wrap to UINT_MAX and clip a third of the frame to white.
const double tex = 20.0 * (static_cast<int>((x + y + phase) % 3) - 1); const double tex = 20.0 * (static_cast<int>((x + y + phase) % 3) - 1);
@ -131,9 +103,7 @@ private:
uint32_t height_; uint32_t height_;
bool started_ = false; bool started_ = false;
long long frames_ = 0; long long frames_ = 0;
double exposure_us_ = 5000.0; std::string mode_;
double gain_db_ = 0.0;
bool auto_on_ = true;
FrameCallback callback_; FrameCallback callback_;
}; };

View File

@ -98,14 +98,10 @@ struct ScanWaypointView {
struct CameraConfigView { struct CameraConfigView {
bool mock = false; bool mock = false;
std::vector<std::string> ids; std::vector<std::string> ids;
std::string pixel_format; // Active camera mode (see CameraMode) and the ones available to switch to.
int binning = 1; std::string mode, mode_desc;
int offset_x = 0, offset_y = 0, width = 0, height = 0; // 0 = full/max int settle_wait_ms = 0;
int throughput_mbytes = 0; std::vector<std::string> modes_available;
double stream_fps = 0.0;
bool exposure_auto = true; double exposure_max_us = 0.0;
bool gain_auto = true; double gain_max_db = 0.0;
bool white_balance_auto = true;
double jxl_distance = 0.0; int jxl_effort = 0; double jxl_distance = 0.0; int jxl_effort = 0;
int rotation_deg = 0; // clockwise; 0 = none int rotation_deg = 0; // clockwise; 0 = none
std::string output_dir; std::string output_dir;
@ -116,7 +112,7 @@ struct CameraDeviceView {
std::string id, model, serial; std::string id, model, serial;
bool streaming = false; bool streaming = false;
unsigned width = 0, height = 0; long long payload_bytes = 0; unsigned width = 0, height = 0; long long payload_bytes = 0;
double exposure_us = 0, gain_db = 0, wb_red = 0, wb_blue = 0, temperature_c = 0, actual_fps = 0; double exposure_us = 0, gain_db = 0, temperature_c = 0, actual_fps = 0;
long long frames_delivered = 0, frames_dropped = 0, restarts = 0; long long frames_delivered = 0, frames_dropped = 0, restarts = 0;
}; };
@ -136,19 +132,6 @@ struct CaptureView {
bool scan_from_file = false; // grid_file CSV vs generated bool scan_from_file = false; // grid_file CSV vs generated
bool scan_pitch = false; // 2-axis rig (show pitch column) bool scan_pitch = false; // 2-axis rig (show pitch column)
std::string scan_error; // grid_file load failure (auto-sweep off) std::string scan_error; // grid_file load failure (auto-sweep off)
// Outcome of the last gated capture (from GatedCameraSource::lastReport()).
// `gate_active` is false when the quality gate is off, in which case the rest
// of these are meaningless.
bool gate_active = false;
bool gate_degraded = false; // budget exhausted; best effort was kept
int gate_attempts = 0;
std::string gate_reason; // "ok" / "clipped" / "dark" / "blur" / ...
double gate_mean_luma = 0.0;
double gate_clipped_pct = 0.0;
double gate_sharpness = 0.0;
double gate_exposure_us = 0.0;
double gate_gain_db = 0.0;
long long gate_degraded_total = 0; // degraded captures this session
// Last published capture (from ImagePipeline::lastEvent()). // Last published capture (from ImagePipeline::lastEvent()).
bool has_last = false; bool has_last = false;

View File

@ -96,5 +96,13 @@ int main(int argc, char* argv[]) {
opts.config_path = *cfg_path; // so `gimbal calib` can offer to save back to it opts.config_path = *cfg_path; // so `gimbal calib` can offer to save back to it
Application app(std::move(cfg), std::move(opts)); Application app(std::move(cfg), std::move(opts));
try {
return app.run(); return app.run();
} catch (const std::exception& e) {
// Startup can still reject the configuration once it touches the
// filesystem (a missing camera-mode directory, say). Report that the way
// a bad config.ini is reported, not as an unhandled terminate.
std::cerr << "Startup failed: " << e.what() << "\n";
return 1;
}
} }

View File

@ -123,7 +123,6 @@ void ImagePipeline::process(const Frame& frame) {
ev.heading_decideg = static_cast<int>(o.yaw_deg * 10); ev.heading_decideg = static_cast<int>(o.yaw_deg * 10);
ev.pitch_decideg = static_cast<int>(o.pitch_deg * 10); ev.pitch_decideg = static_cast<int>(o.pitch_deg * 10);
ev.timestamp_ms = frame.timestamp_ms; ev.timestamp_ms = frame.timestamp_ms;
ev.degraded = frame.degraded;
channel_.publishCamEvent(ev); channel_.publishCamEvent(ev);
{ {
std::lock_guard<std::mutex> lock(last_event_mutex_); std::lock_guard<std::mutex> lock(last_event_mutex_);

View File

@ -1,5 +1,16 @@
#include "fgc/VimbaCameraSource.h" #include "fgc/VimbaCameraSource.h"
// The SDK comes first on purpose. <VmbCPP/VmbCPP.h> pulls in
// VmbCPP/LoggerDefines.h, which on some SDK versions (the 2024-era /opt/VimbaX on
// the LattePanda, though not 2025-3) defines a FUNCTION-like LOG_ERROR(txt,
// errCode). Ours is object-like - `LOG_ERROR << "..."` - so whichever header is
// included last wins the redefinition, and when theirs wins the stream form never
// expands and fails to compile. Take the SDK's macros, drop them, then define
// ours over the top. The #undefs are harmless no-ops on SDKs that lack them.
#include <VmbCPP/VmbCPP.h>
#undef LOG_ERROR
#undef LOG_FREE_TEXT
#include "fgc/Logger.h" #include "fgc/Logger.h"
#include <algorithm> #include <algorithm>
@ -8,10 +19,9 @@
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <stdexcept> #include <stdexcept>
#include <string>
#include <thread> #include <thread>
#include <VmbCPP/VmbCPP.h>
using namespace VmbCPP; using namespace VmbCPP;
namespace fgc { namespace fgc {
@ -35,10 +45,10 @@ struct CamStats {
std::atomic<long long> last_bytes{0}; std::atomic<long long> last_bytes{0};
}; };
// Frame observer for a paced free-running stream: keeps the most recent COMPLETE // Frame observer for the free-running stream: keeps the most recent COMPLETE
// frame. trigger() (once per scan waypoint) reads it out; the intermediate frames // frame. trigger() (once per scan waypoint) reads it out; the intermediate frames
// just give the on-camera auto-exposure/gain/white-balance time to converge to the // just give the on-camera auto-exposure/gain time to converge to the current
// current light. This avoids the software-trigger path, which stalls on this host. // light.
class FrameObserver : public IFrameObserver { class FrameObserver : public IFrameObserver {
public: public:
FrameObserver(CameraPtr camera, int cam_id, CamStats* stats) FrameObserver(CameraPtr camera, int cam_id, CamStats* stats)
@ -108,43 +118,6 @@ private:
bool has_ = false; bool has_ = false;
}; };
// Feature setters: log (but tolerate) failures - cameras vary in which features they
// expose, so a missing/locked feature is a warning, not fatal.
void setEnum(const CameraPtr& cam, const char* name, const char* value) {
FeaturePtr f;
if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess &&
f->SetValue(value) == VmbErrorSuccess)
return;
LOG_WARN << "camera: could not set " << name << '=' << value;
}
void setInt(const CameraPtr& cam, const char* name, VmbInt64_t value) {
FeaturePtr f;
if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess &&
f->SetValue(value) == VmbErrorSuccess)
return;
LOG_WARN << "camera: could not set " << name << '=' << value;
}
void setFloat(const CameraPtr& cam, const char* name, double value) {
FeaturePtr f;
if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess &&
f->SetValue(value) == VmbErrorSuccess)
return;
LOG_WARN << "camera: could not set " << name << '=' << value;
}
void setBool(const CameraPtr& cam, const char* name, bool value) {
FeaturePtr f;
if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess &&
f->SetValue(value) == VmbErrorSuccess)
return;
LOG_WARN << "camera: could not set " << name << '=' << (value ? "true" : "false");
}
VmbInt64_t featureValue(const CameraPtr& cam, const char* name, VmbInt64_t fallback) {
FeaturePtr f;
VmbInt64_t v = fallback;
if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess && f->GetValue(v) == VmbErrorSuccess)
return v;
return fallback;
}
// Read a float/string feature, returning `fallback`/"" if absent (tolerant, for telemetry). // Read a float/string feature, returning `fallback`/"" if absent (tolerant, for telemetry).
double getFloat(const CameraPtr& cam, const char* name, double fallback = 0.0) { double getFloat(const CameraPtr& cam, const char* name, double fallback = 0.0) {
FeaturePtr f; FeaturePtr f;
@ -160,77 +133,67 @@ std::string getString(const CameraPtr& cam, const char* name) {
return v; return v;
return {}; return {};
} }
// White-balance ratio for a colour channel ("Red"/"Blue"): select then read.
double wbRatio(const CameraPtr& cam, const char* channel) { bool parseBool(const std::string& v) {
FeaturePtr sel; return v == "1" || v == "true" || v == "True" || v == "yes" || v == "on";
if (SP_ACCESS(cam)->GetFeatureByName("BalanceRatioSelector", sel) == VmbErrorSuccess)
sel->SetValue(channel);
return getFloat(cam, "BalanceRatio", 0.0);
} }
// Apply the config-driven imaging settings, in-session. Order per the Alvium user // Write one feature from a mode file, converting the string to whatever type the
// guide: ROI/binning -> throughput -> trigger -> exposure. // camera says the feature is. Mode files are hand-edited and cameras differ in
// // which features they expose (and which are locked at a given moment), so a
// Two acquisition modes: // failure is a warning, never fatal - the warning IS the feedback that a mode
// triggered (default) - TriggerMode=On + TriggerSource=Software: exactly one frame // file has a typo in it.
// per TriggerSoftware, so the frame belongs to the waypoint it was asked for and void applyFeature(const CameraPtr& cam, const std::string& name, const std::string& value) {
// its exposure can be chosen deliberately. This is also a LOWER USB load than a FeaturePtr f;
// continuous stream, which matters on this host (see docs/known-issues.md). if (SP_ACCESS(cam)->GetFeatureByName(name.c_str(), f) != VmbErrorSuccess) {
// freerun - the older paced stream + keep-latest, kept as a fallback. LOG_WARN << "camera: no such feature '" << name << "' (skipped)";
// return;
// `manual_exposure` means the quality gate owns exposure/gain, so the on-camera auto
// is switched off.
void configureCamera(const CameraPtr& cam, const CameraConfig& c, bool triggered,
bool manual_exposure) {
setInt(cam, "BinningHorizontal", c.binning);
setInt(cam, "BinningVertical", c.binning);
setInt(cam, "OffsetX", 0);
setInt(cam, "OffsetY", 0);
setInt(cam, "Width", c.width > 0 ? c.width : featureValue(cam, "WidthMax", 0));
setInt(cam, "Height", c.height > 0 ? c.height : featureValue(cam, "HeightMax", 0));
if (c.offset_x > 0) setInt(cam, "OffsetX", c.offset_x);
if (c.offset_y > 0) setInt(cam, "OffsetY", c.offset_y);
setEnum(cam, "PixelFormat", c.pixel_format.c_str());
setEnum(cam, "DeviceLinkThroughputLimitMode", "On");
setInt(cam, "DeviceLinkThroughputLimit", static_cast<VmbInt64_t>(c.throughput_mbytes) * 1000000);
setEnum(cam, "TriggerSelector", "FrameStart");
// AcquisitionFrameRateEnable=On makes TriggerMode (FrameStart) read-only, so the rate
// control must be dropped FIRST in either mode - otherwise the TriggerMode write is
// silently rejected and the camera stays in whatever mode the last run left it in.
setBool(cam, "AcquisitionFrameRateEnable", false);
setEnum(cam, "AcquisitionMode", "Continuous");
if (triggered) {
// One deliberate frame per TriggerSoftware. The camera ships in HARDWARE trigger
// mode (TriggerSource=Line0) and produces nothing until the source is redirected,
// so Source must be set explicitly - it is not enough to enable TriggerMode.
setEnum(cam, "TriggerSource", "Software");
setEnum(cam, "TriggerMode", "On");
} else {
setEnum(cam, "TriggerMode", "Off"); // free-run
setBool(cam, "AcquisitionFrameRateEnable", true);
setFloat(cam, "AcquisitionFrameRate", c.stream_fps);
} }
// With the quality gate driving exposure, the camera's own continuous auto would VmbFeatureDataType type = VmbFeatureDataUnknown;
// fight it - and its several-frames-to-converge cost is exactly what we are avoiding. if (f->GetDataType(type) != VmbErrorSuccess) {
const bool auto_exposure = c.exposure_auto && !manual_exposure; LOG_WARN << "camera: cannot read type of feature '" << name << "' (skipped)";
const bool auto_gain = c.gain_auto && !manual_exposure; return;
setEnum(cam, "ExposureAuto", auto_exposure ? "Continuous" : "Off"); }
if (c.exposure_max_us > 0.0) setFloat(cam, "ExposureAutoMax", c.exposure_max_us);
setEnum(cam, "GainAuto", auto_gain ? "Continuous" : "Off");
if (c.gain_max_db > 0.0) setFloat(cam, "GainAutoMax", c.gain_max_db);
// White balance stays on the camera even under manual exposure: it is far less
// time-critical and is correctable in post, unlike a blown or blurred frame.
setEnum(cam, "BalanceWhiteAuto", c.white_balance_auto ? "Continuous" : "Off");
LOG_INFO << "camera configured: binning=" << c.binning << " fmt=" << c.pixel_format VmbErrorType rc = VmbErrorSuccess;
<< " mode=" << (triggered ? "trigger" : "freerun@" + std::to_string(c.stream_fps) + "fps") try {
<< " throughput=" << c.throughput_mbytes << "MB/s" switch (type) {
<< " autoExp=" << auto_exposure << " autoWB=" << c.white_balance_auto; case VmbFeatureDataInt:
rc = f->SetValue(static_cast<VmbInt64_t>(std::stoll(value)));
break;
case VmbFeatureDataFloat:
rc = f->SetValue(std::stod(value));
break;
case VmbFeatureDataBool:
rc = f->SetValue(parseBool(value));
break;
case VmbFeatureDataEnum:
case VmbFeatureDataString:
rc = f->SetValue(value.c_str());
break;
default:
LOG_WARN << "camera: feature '" << name << "' is not settable from a mode file";
return;
}
} catch (const std::exception&) {
LOG_WARN << "camera: feature '" << name << "' rejected value '" << value
<< "' (wrong number format)";
return;
}
if (rc != VmbErrorSuccess)
LOG_WARN << "camera: could not set " << name << '=' << value << " (error " << rc << ')';
else
LOG_TRACE_CAT(LogCat::Camera) << "SET " << name << '=' << value;
}
// Apply a whole mode, IN FILE ORDER - GenICam selectors are stateful, so order is
// part of the meaning (see CameraMode.h).
void applyMode(const CameraPtr& cam, const CameraMode& mode) {
for (const auto& [name, value] : mode.features) applyFeature(cam, name, value);
LOG_INFO << "camera mode '" << mode.name << "' applied (" << mode.features.size()
<< " features)";
} }
} // namespace } // namespace
@ -240,12 +203,11 @@ struct VimbaCameraSource::Impl {
struct Identity { std::string id, model, serial; }; struct Identity { std::string id, model, serial; };
struct Telemetry { struct Telemetry {
double exposure_us = 0, gain_db = 0, wb_red = 0, wb_blue = 0, temperature_c = 0, actual_fps = 0; double exposure_us = 0, gain_db = 0, temperature_c = 0, actual_fps = 0;
}; };
CameraConfig config; CameraConfig config;
bool triggered = true; // software trigger vs free-run CameraMode mode; // applied at start() and by setMode()
bool manual_exposure = true; // gate owns exposure/gain
VmbSystem& sys; VmbSystem& sys;
std::vector<CameraPtr> cameras; std::vector<CameraPtr> cameras;
std::vector<IFrameObserverPtr> observers; std::vector<IFrameObserverPtr> observers;
@ -283,31 +245,6 @@ struct VimbaCameraSource::Impl {
return t; return t;
} }
// Fire TriggerSoftware on EVERY camera (the old implementation triggered only
// cameras[0], which is why multi-camera rigs saw nothing from the others).
bool fireSoftwareTrigger() {
bool any = false;
for (auto& cam : cameras) {
FeaturePtr f;
if (SP_ACCESS(cam)->GetFeatureByName("TriggerSoftware", f) == VmbErrorSuccess &&
f->RunCommand() == VmbErrorSuccess)
any = true;
}
if (!any) LOG_WARN << "camera: TriggerSoftware command failed";
return any;
}
// Set a float feature on every camera, tolerating cameras that lack it.
bool setFloatAll(const char* name, double value) {
bool any = false;
for (auto& cam : cameras) {
FeaturePtr f;
if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess &&
f->SetValue(value) == VmbErrorSuccess)
any = true;
}
return any;
}
}; };
VimbaCameraSource::VimbaCameraSource(CameraConfig config) VimbaCameraSource::VimbaCameraSource(CameraConfig config)
@ -347,25 +284,41 @@ void VimbaCameraSource::close() {
impl_->sys.Shutdown(); impl_->sys.Shutdown();
} }
void VimbaCameraSource::setAcquisitionMode(bool triggered, bool manual_exposure) { void VimbaCameraSource::setInitialMode(CameraMode mode) { impl_->mode = std::move(mode); }
impl_->triggered = triggered;
impl_->manual_exposure = manual_exposure;
}
void VimbaCameraSource::start() { void VimbaCameraSource::start() {
for (auto& cam : impl_->cameras) for (auto& cam : impl_->cameras) applyMode(cam, impl_->mode);
configureCamera(cam, impl_->config, impl_->triggered, impl_->manual_exposure);
impl_->startAcquisition(); impl_->startAcquisition();
impl_->started = true; impl_->started = true;
} }
bool VimbaCameraSource::setMode(const CameraMode& mode) {
impl_->mode = mode;
if (!impl_->started) return true; // start() will apply it
// Binning, ROI and pixel format cannot be written while the stream runs, and a
// mode may change any of them, so always cycle the stream. Mode switches are
// rare; a per-feature "is this writable live" allow-list would be fragile.
try {
impl_->stopAcquisition();
for (auto& cam : impl_->cameras) applyMode(cam, impl_->mode);
impl_->startAcquisition();
} catch (const std::exception& e) {
LOG_ERROR << "camera: applying mode '" << mode.name << "' failed: " << e.what();
return false;
}
// The next trigger() must not hand back a frame exposed under the old mode.
impl_->last_captured_ts = nowMs();
return true;
}
void VimbaCameraSource::stop() { void VimbaCameraSource::stop() {
impl_->stopAcquisition(); impl_->stopAcquisition();
impl_->started = false; impl_->started = false;
} }
bool VimbaCameraSource::trigger() { bool VimbaCameraSource::trigger() {
// Paced free-run: deliver the freshest complete frame per waypoint. This host's USB3 // Deliver the freshest complete frame per waypoint. This host's USB3
// stream reliably delivers the first frames of a session but stalls after a burst; if // stream reliably delivers the first frames of a session but stalls after a burst; if
// no frame newer than the last capture is available, the stream has stalled - restart // no frame newer than the last capture is available, the stream has stalled - restart
// acquisition to unstick it and wait briefly for a fresh frame. Without this, keep-latest // acquisition to unstick it and wait briefly for a fresh frame. Without this, keep-latest
@ -406,72 +359,6 @@ bool VimbaCameraSource::trigger() {
return any; return any;
} }
bool VimbaCameraSource::acquireFrame(Frame& out, int timeout_ms) {
if (impl_->observers.empty()) return false;
// Watermark first, then trigger: any frame newer than this is the one we asked
// for, so a frame already in flight can never be mistaken for the new capture.
const long long before = impl_->newestTs();
if (impl_->triggered && !impl_->fireSoftwareTrigger()) return false;
const long long deadline = nowMs() + timeout_ms;
while (impl_->newestTs() <= before && nowMs() < deadline)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
if (impl_->newestTs() <= before) {
LOG_WARN << "camera: no frame within " << timeout_ms << " ms of trigger";
return false;
}
// Take the freshest frame from whichever camera delivered it.
bool got = false;
for (auto& obs : impl_->observers) {
Frame f;
if (SP_DYN_CAST<FrameObserver>(obs)->takeLatest(f) && f.timestamp_ms > before) {
if (!got || f.timestamp_ms > out.timestamp_ms) out = std::move(f);
got = true;
}
}
if (got) impl_->last_captured_ts = std::max(impl_->last_captured_ts, out.timestamp_ms);
return got;
}
bool VimbaCameraSource::setExposure(double us) {
return impl_->setFloatAll("ExposureTime", us);
}
bool VimbaCameraSource::setGain(double db) { return impl_->setFloatAll("Gain", db); }
bool VimbaCameraSource::setAutoExposureGain(bool on) {
const char* v = on ? "Continuous" : "Off";
for (auto& cam : impl_->cameras) {
setEnum(cam, "ExposureAuto", v);
setEnum(cam, "GainAuto", v);
}
impl_->manual_exposure = !on;
return true;
}
double VimbaCameraSource::currentGain() {
if (impl_->cameras.empty()) return 0.0;
return getFloat(impl_->cameras.front(), "Gain", 0.0);
}
bool VimbaCameraSource::setFrameRate(double fps) {
VmbErrorType result = VmbErrorSuccess;
for (auto& cam : impl_->cameras) {
FeaturePtr feature;
if (SP_ACCESS(cam)->GetFeatureByName("AcquisitionFrameRate", feature) == VmbErrorSuccess)
result = feature->SetValue(fps);
}
if (result == VmbErrorSuccess) {
impl_->config.stream_fps = fps;
LOG_INFO << "camera fps set to " << fps;
}
return result == VmbErrorSuccess;
}
void VimbaCameraSource::setFrameCallback(FrameCallback cb) { impl_->callback = std::move(cb); } void VimbaCameraSource::setFrameCallback(FrameCallback cb) { impl_->callback = std::move(cb); }
int VimbaCameraSource::cameraCount() const { return static_cast<int>(impl_->cameras.size()); } int VimbaCameraSource::cameraCount() const { return static_cast<int>(impl_->cameras.size()); }
@ -490,8 +377,6 @@ std::vector<CameraDeviceInfo> VimbaCameraSource::deviceInfo() {
t.gain_db = getFloat(cam, "Gain"); t.gain_db = getFloat(cam, "Gain");
t.temperature_c = getFloat(cam, "DeviceTemperature"); t.temperature_c = getFloat(cam, "DeviceTemperature");
t.actual_fps = getFloat(cam, "AcquisitionFrameRate"); t.actual_fps = getFloat(cam, "AcquisitionFrameRate");
t.wb_red = wbRatio(cam, "Red");
t.wb_blue = wbRatio(cam, "Blue");
} }
} }
@ -510,8 +395,6 @@ std::vector<CameraDeviceInfo> VimbaCameraSource::deviceInfo() {
d.payload_bytes = s.last_bytes.load(); d.payload_bytes = s.last_bytes.load();
d.exposure_us = t.exposure_us; d.exposure_us = t.exposure_us;
d.gain_db = t.gain_db; d.gain_db = t.gain_db;
d.wb_red = t.wb_red;
d.wb_blue = t.wb_blue;
d.temperature_c = t.temperature_c; d.temperature_c = t.temperature_c;
d.actual_fps = t.actual_fps; d.actual_fps = t.actual_fps;
d.frames_delivered = s.delivered.load(); d.frames_delivered = s.delivered.load();

View File

@ -7,8 +7,7 @@
#include "fgc/DumpParser.h" #include "fgc/DumpParser.h"
#include "fgc/HelpText.h" #include "fgc/HelpText.h"
#include "fgc/Paths.h" #include "fgc/Paths.h"
#include "fgc/ExposureStore.h" #include "fgc/CameraMode.h"
#include "fgc/GatedCameraSource.h"
#include "fgc/ICameraSource.h" #include "fgc/ICameraSource.h"
#include "fgc/IControlChannel.h" #include "fgc/IControlChannel.h"
#include "fgc/IEnvSensor.h" #include "fgc/IEnvSensor.h"
@ -183,12 +182,10 @@ struct Application::Impl {
std::unique_ptr<IControlChannel> channel; std::unique_ptr<IControlChannel> channel;
std::unique_ptr<IMotorController> motor; std::unique_ptr<IMotorController> motor;
std::unique_ptr<ICameraSource> camera; std::unique_ptr<ICameraSource> camera;
// Borrowed view of the quality gate inside `camera` (null when it is off), so // Camera modes, loaded once at startup and kept so a live switch needs no disk
// buildSnapshot can report the last capture's verdict without a downcast. // access. `camera_mode` is the active one.
GatedCameraSource* gate = nullptr; std::map<std::string, CameraMode> camera_modes;
// Per-angle exposure memory. Owned here (not by the gate) so it outlives the std::string camera_mode;
// camera and can be flushed to disk on shutdown.
std::unique_ptr<ExposureStore> exposure_store;
std::unique_ptr<IImuSource> imu; std::unique_ptr<IImuSource> imu;
std::unique_ptr<IEnvSensor> env; std::unique_ptr<IEnvSensor> env;
std::unique_ptr<ImagePipeline> pipeline; std::unique_ptr<ImagePipeline> pipeline;
@ -290,8 +287,7 @@ struct Application::Impl {
std::chrono::milliseconds(cfg.env.period_ms)); std::chrono::milliseconds(cfg.env.period_ms));
} }
// The bare camera driver, before any quality gating is layered on. std::unique_ptr<ICameraSource> makeCamera() {
std::unique_ptr<ICameraSource> makeRawCamera() {
bool mock = opts.mock_camera.value_or(cfg.features.mock_camera); bool mock = opts.mock_camera.value_or(cfg.features.mock_camera);
#if !FGC_WITH_VIMBA #if !FGC_WITH_VIMBA
if (!mock) { if (!mock) {
@ -305,67 +301,44 @@ struct Application::Impl {
} }
#if FGC_WITH_VIMBA #if FGC_WITH_VIMBA
auto cam = std::make_unique<VimbaCameraSource>(cfg.camera); auto cam = std::make_unique<VimbaCameraSource>(cfg.camera);
cam->setAcquisitionMode(cfg.capture.mode == "trigger", cfg.capture.quality_gate); cam->setInitialMode(camera_modes.at(camera_mode));
return cam; return cam;
#else #else
return std::make_unique<MockCameraSource>(1); return std::make_unique<MockCameraSource>(1);
#endif #endif
} }
// Wrap the driver in the quality gate, which turns one trigger() into // Load every mode file up front, so a typo in a mode nobody is using today
// shoot -> judge -> correct -> reshoot, seeded from the per-angle store. // still fails at startup rather than the first time someone switches to it.
std::unique_ptr<ICameraSource> makeCamera() { void loadModes() {
auto raw = makeRawCamera(); camera_modes = fgc::loadCameraModes(cfg.camera.mode_dir);
if (!cfg.capture.quality_gate) { if (camera_modes.find(cfg.camera.mode) == camera_modes.end()) {
LOG_INFO << "capture: quality gate disabled (mode=" << cfg.capture.mode << ')'; std::string known;
return raw; for (const auto& [name, m] : camera_modes) known += (known.empty() ? "" : ", ") + name;
throw std::runtime_error("Camera.mode '" + cfg.camera.mode + "' has no " +
cfg.camera.mode + ".ini in " + cfg.camera.mode_dir +
" (available: " + known + ")");
}
camera_mode = cfg.camera.mode;
LOG_INFO << "camera modes: " << camera_modes.size() << " from " << cfg.camera.mode_dir
<< "; active '" << camera_mode << "'";
} }
exposure_store = std::make_unique<ExposureStore>( // Switch the live camera mode by name. Returns false (and warns) if unknown.
cfg.capture.exposure_store, cfg.capture.angle_quantum_deg, cfg.capture.store_stale_s); bool applyMode(const std::string& name) {
if (exposure_store->load()) auto it = camera_modes.find(name);
LOG_INFO << "exposure store: " << exposure_store->size() << " angle(s) from " if (it == camera_modes.end()) {
<< exposure_store->path(); std::string known;
else for (const auto& [n, m] : camera_modes) known += (known.empty() ? "" : ", ") + n;
LOG_INFO << "exposure store: starting empty (" << exposure_store->path() << ')'; LOG_WARN << "unknown camera mode '" << name << "' (available: " << known << ')';
return false;
GatedCameraSource::Params p; }
p.enabled = true; if (camera) camera->setMode(it->second);
p.max_attempts = cfg.capture.max_attempts; camera_mode = name;
p.min_attempts = cfg.capture.min_attempts; if (scheduler)
p.acquire_timeout_ms = cfg.capture.acquire_timeout_ms; scheduler->setSettleWait(it->second.settle_wait_ms, it->second.frame_timeout_ms);
p.settle_delay_ms = cfg.capture.settle_delay_ms; LOG_INFO << "camera mode -> " << name << " (" << it->second.description << ')';
p.defaults = {cfg.capture.default_exposure_us, cfg.capture.default_gain_db}; return true;
p.quality.stride = cfg.capture.metric_stride;
p.quality.sharpness_roi_px = cfg.capture.sharpness_roi_px;
p.policy.target_mean = cfg.capture.target_mean;
p.policy.mean_tolerance = cfg.capture.mean_tolerance;
p.policy.clip_max_fraction = cfg.capture.clip_max_fraction;
p.policy.exposure_min_us = cfg.capture.exposure_min_us;
// The exposure ceiling doubles as the motion-blur budget; fall back to a
// sane cap when [Camera] leaves it at "camera default".
p.policy.exposure_max_us = cfg.camera.exposure_max_us > 0.0
? cfg.camera.exposure_max_us
: 20000.0;
p.policy.gain_max_db = cfg.camera.gain_max_db > 0.0 ? cfg.camera.gain_max_db : 12.0;
p.policy.damping = cfg.capture.damping;
p.policy.blur_relative_floor = cfg.capture.blur_relative_floor;
p.policy.blur_absolute_floor = cfg.capture.blur_absolute_floor;
LOG_INFO << "capture: " << cfg.capture.mode << " mode, quality gate on (target mean "
<< cfg.capture.target_mean << ", up to " << cfg.capture.max_attempts
<< " attempts)";
// Where the gimbal is pointing now, for keying the store - same encoder->degrees
// conversion the ImagePipeline orientation supplier uses.
auto angle = [this]() -> std::pair<double, double> {
MotorTelemetry t = motor->telemetry();
return {cfg.geometry.yaw.toDeg(t.yaw.xenc), cfg.geometry.pitch.toDeg(t.pitch.xenc)};
};
auto gated = std::make_unique<GatedCameraSource>(std::move(raw), exposure_store.get(), p,
angle);
gate = gated.get(); // borrowed, for the snapshot; owned by `camera`
return gated;
} }
std::unique_ptr<IUserInterface> makeUi() { std::unique_ptr<IUserInterface> makeUi() {
@ -548,19 +521,13 @@ struct Application::Impl {
auto& cc = s.capture.config; auto& cc = s.capture.config;
cc.mock = opts.mock_camera.value_or(cfg.features.mock_camera); cc.mock = opts.mock_camera.value_or(cfg.features.mock_camera);
cc.ids = cfg.camera.ids; cc.ids = cfg.camera.ids;
cc.pixel_format = cfg.camera.pixel_format; cc.mode = camera_mode;
cc.binning = cfg.camera.binning; if (auto it = camera_modes.find(camera_mode); it != camera_modes.end()) {
cc.offset_x = cfg.camera.offset_x; cc.mode_desc = it->second.description;
cc.offset_y = cfg.camera.offset_y; cc.settle_wait_ms = it->second.settle_wait_ms;
cc.width = cfg.camera.width; }
cc.height = cfg.camera.height; cc.modes_available.clear();
cc.throughput_mbytes = cfg.camera.throughput_mbytes; for (const auto& [name, m] : camera_modes) cc.modes_available.push_back(name);
cc.stream_fps = cfg.camera.stream_fps;
cc.exposure_auto = cfg.camera.exposure_auto;
cc.exposure_max_us = cfg.camera.exposure_max_us;
cc.gain_auto = cfg.camera.gain_auto;
cc.gain_max_db = cfg.camera.gain_max_db;
cc.white_balance_auto = cfg.camera.white_balance_auto;
cc.jxl_distance = cfg.camera.jxl_distance; cc.jxl_distance = cfg.camera.jxl_distance;
cc.jxl_effort = cfg.camera.jxl_effort; cc.jxl_effort = cfg.camera.jxl_effort;
cc.rotation_deg = cfg.camera.rotation_deg; cc.rotation_deg = cfg.camera.rotation_deg;
@ -574,7 +541,6 @@ struct Application::Impl {
v.id = d.id; v.model = d.model; v.serial = d.serial; v.streaming = d.streaming; v.id = d.id; v.model = d.model; v.serial = d.serial; v.streaming = d.streaming;
v.width = d.width; v.height = d.height; v.payload_bytes = d.payload_bytes; v.width = d.width; v.height = d.height; v.payload_bytes = d.payload_bytes;
v.exposure_us = d.exposure_us; v.gain_db = d.gain_db; v.exposure_us = d.exposure_us; v.gain_db = d.gain_db;
v.wb_red = d.wb_red; v.wb_blue = d.wb_blue;
v.temperature_c = d.temperature_c; v.actual_fps = d.actual_fps; v.temperature_c = d.temperature_c; v.actual_fps = d.actual_fps;
v.frames_delivered = d.frames_delivered; v.frames_dropped = d.frames_dropped; v.frames_delivered = d.frames_delivered; v.frames_dropped = d.frames_dropped;
v.restarts = d.restarts; v.restarts = d.restarts;
@ -583,20 +549,6 @@ struct Application::Impl {
} }
s.capture.images_saved = pipeline ? pipeline->imagesSaved() : 0; s.capture.images_saved = pipeline ? pipeline->imagesSaved() : 0;
// Quality-gate verdict for the last waypoint.
if (gate) {
const CaptureReport& r = gate->lastReport();
s.capture.gate_active = true;
s.capture.gate_degraded = r.degraded;
s.capture.gate_attempts = r.attempts;
s.capture.gate_reason = r.reason;
s.capture.gate_mean_luma = r.metrics.mean_luma;
s.capture.gate_clipped_pct = r.metrics.clipped_fraction * 100.0;
s.capture.gate_sharpness = r.metrics.sharpness;
s.capture.gate_exposure_us = r.settings.exposure_us;
s.capture.gate_gain_db = r.settings.gain_db;
s.capture.gate_degraded_total = gate->degradedTotal();
}
// Defined scan grid + live cursor (read on the control thread, same as the // Defined scan grid + live cursor (read on the control thread, same as the
// scheduler that mutates it, so no locking is needed). // scheduler that mutates it, so no locking is needed).
s.capture.scan_from_file = !cfg.scan.grid_file.empty(); s.capture.scan_from_file = !cfg.scan.grid_file.empty();
@ -1426,7 +1378,10 @@ struct Application::Impl {
handleTrace(c); handleTrace(c);
} else if (c.verb == "set") { } else if (c.verb == "set") {
if (c.device == "camera") { if (c.device == "camera") {
if (c.option == "fps" && c.has_value) camera->setFrameRate(c.value); // "set camera mode night" arrives as option="mode night": the parser
// joins non-numeric trailing tokens into `option`.
if (c.option.rfind("mode ", 0) == 0) applyMode(c.option.substr(5));
else if (c.option == "mode") LOG_WARN << "usage: set camera mode <name>";
else if (c.option == "jxlq" && c.has_value) pipeline->setDistance(c.value); else if (c.option == "jxlq" && c.has_value) pipeline->setDistance(c.value);
else if (c.option == "jxle" && c.has_value) pipeline->setEffort(static_cast<int>(c.value)); else if (c.option == "jxle" && c.has_value) pipeline->setEffort(static_cast<int>(c.value));
else if (c.option == "display") pipeline->setDisplay(c.value != 0.0); else if (c.option == "display") pipeline->setDisplay(c.value != 0.0);
@ -1479,6 +1434,8 @@ struct Application::Impl {
if (Logger::categories()) if (Logger::categories())
LOG_INFO << "wire trace enabled: " << traceNames(Logger::categories()); LOG_INFO << "wire trace enabled: " << traceNames(Logger::categories());
loadModes(); // before makeCamera(): the mode is applied at start()
channel = makeChannel(); channel = makeChannel();
motor = makeMotor(); motor = makeMotor();
camera = makeCamera(); camera = makeCamera();
@ -1533,6 +1490,10 @@ struct Application::Impl {
LOG_INFO << "scan grid: " << grid.size() << " waypoints"; LOG_INFO << "scan grid: " << grid.size() << " waypoints";
scheduler = std::make_unique<CaptureScheduler>(*motor, *camera, *channel, cfg.image_rate(), scheduler = std::make_unique<CaptureScheduler>(*motor, *camera, *channel, cfg.image_rate(),
cfg.geometry, grid); cfg.geometry, grid);
{
const CameraMode& m = camera_modes.at(camera_mode);
scheduler->setSettleWait(m.settle_wait_ms, m.frame_timeout_ms);
}
motor->start(); motor->start();
if (imu) imu->start(); if (imu) imu->start();
@ -1592,10 +1553,6 @@ struct Application::Impl {
pipeline->stop(); pipeline->stop();
camera->stop(); camera->stop();
camera->close(); camera->close();
// Persist the per-angle exposure memory so the next run starts warm.
if (exposure_store && exposure_store->save())
LOG_INFO << "exposure store: saved " << exposure_store->size() << " angle(s) to "
<< exposure_store->path();
if (imu) imu->stop(); if (imu) imu->stop();
if (env) env->stop(); if (env) env->stop();
motor->stop(); motor->stop();

95
src/core/CameraMode.cpp Normal file
View File

@ -0,0 +1,95 @@
#include "fgc/CameraMode.h"
#include <algorithm>
#include <filesystem>
#include <stdexcept>
#include <string>
extern "C" {
#include "ini.h"
}
namespace fs = std::filesystem;
namespace fgc {
namespace {
// inih callback. Unlike Config.cpp's handler this APPENDS rather than writing a
// map: mode files depend on file order and on repeated keys (see CameraMode).
int modeHandler(void* user, const char* section, const char* name, const char* value) {
auto* m = static_cast<CameraMode*>(user);
const std::string sec = section ? section : "";
const std::string key = name ? name : "";
const std::string val = value ? value : "";
if (sec == "Camera") {
m->features.emplace_back(key, val);
return 1;
}
if (sec != "Mode") return 0; // unknown section -> parse error
if (key == "description") {
m->description = val;
} else if (key == "settle_wait_ms" || key == "frame_timeout_ms") {
int parsed = 0;
try {
size_t consumed = 0;
parsed = std::stoi(val, &consumed);
if (consumed != val.size() || parsed < 0) return 0;
} catch (const std::exception&) {
return 0;
}
(key == "settle_wait_ms" ? m->settle_wait_ms : m->frame_timeout_ms) = parsed;
} else {
return 0; // unknown [Mode] key -> parse error, not a silent typo
}
return 1;
}
} // namespace
CameraMode loadCameraMode(const std::string& path) {
CameraMode mode;
mode.name = fs::path(path).stem().string();
const int rc = ini_parse(path.c_str(), modeHandler, &mode);
if (rc == -1)
throw std::runtime_error("Cannot open camera mode file: " + path);
if (rc == -2)
throw std::runtime_error("Out of memory reading camera mode file: " + path);
if (rc > 0)
throw std::runtime_error("Camera mode file '" + path + "' line " + std::to_string(rc) +
": unknown section, unknown [Mode] key, or invalid value "
"(expected [Mode] description/settle_wait_ms/frame_timeout_ms, "
"or a [Camera] feature)");
if (mode.features.empty())
throw std::runtime_error("Camera mode file '" + path + "' sets no [Camera] features");
return mode;
}
std::map<std::string, CameraMode> loadCameraModes(const std::string& dir) {
std::error_code ec;
if (!fs::is_directory(dir, ec))
throw std::runtime_error("Camera mode directory not found: " + dir);
// Sorted so startup logs and the UI list modes in a stable order.
std::vector<fs::path> files;
for (const auto& entry : fs::directory_iterator(dir, ec))
if (entry.is_regular_file() && entry.path().extension() == ".ini")
files.push_back(entry.path());
std::sort(files.begin(), files.end());
std::map<std::string, CameraMode> modes;
for (const auto& f : files) {
CameraMode m = loadCameraMode(f.string());
modes.emplace(m.name, std::move(m));
}
if (modes.empty())
throw std::runtime_error("No camera modes (*.ini) in " + dir);
return modes;
}
} // namespace fgc

View File

@ -37,10 +37,16 @@ CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camer
void CaptureScheduler::setCaptureActive(bool active) { void CaptureScheduler::setCaptureActive(bool active) {
capture_active_ = active; capture_active_ = active;
moving_ = false; // restart the move/settle cycle cleanly moving_ = false; // restart the move/settle cycle cleanly
settled_at_ms_ = 0;
resetTimer(); resetTimer();
} }
void CaptureScheduler::setImageRate(double rate) { image_rate_ = rate; } void CaptureScheduler::setImageRate(double rate) { image_rate_ = rate; }
void CaptureScheduler::setSettleWait(int settle_ms, int timeout_ms) {
settle_wait_ms_ = settle_ms < 0 ? 0 : settle_ms;
frame_timeout_ms_ = timeout_ms < 0 ? 0 : timeout_ms;
}
long long CaptureScheduler::elapsedMs() const { return now_ms_() - timer_start_; } long long CaptureScheduler::elapsedMs() const { return now_ms_() - timer_start_; }
void CaptureScheduler::resetTimer() { timer_start_ = now_ms_(); } void CaptureScheduler::resetTimer() { timer_start_ = now_ms_(); }
@ -110,24 +116,48 @@ void CaptureScheduler::tick() {
} }
sendMove(); sendMove();
moving_ = true; moving_ = true;
settled_at_ms_ = 0;
resetTimer(); resetTimer();
return; return;
} }
// Moving: trigger once both axes have settled at the target. // Moving: wait for standstill at the target, then hold still a moment before
if (settledAt(t)) { // keeping a frame.
if (settled_at_ms_ == 0) {
if (!settledAt(t)) return;
settled_at_ms_ = now_ms_();
LOG_TRACE_CAT(LogCat::Control) LOG_TRACE_CAT(LogCat::Control)
<< "settled at yaw=" << t.yaw.xenc << " pitch=" << t.pitch.xenc << "; triggering"; << "settled at yaw=" << t.yaw.xenc << " pitch=" << t.pitch.xenc << "; waiting "
<< settle_wait_ms_ << " ms before capture";
return;
}
// The camera free-runs, so this pause is what makes the kept frame one that was
// exposed with the gimbal at rest (no slew blur) and with the on-camera auto
// exposure settled on this waypoint's light.
const long long since_settled = now_ms_() - settled_at_ms_;
if (since_settled < settle_wait_ms_) return;
if (camera_.trigger()) { if (camera_.trigger()) {
if (control_code_ == 0) if (control_code_ == 0)
LOG_INFO << "captured waypoint " << (grid_.index() + 1) << "/" << grid_.size(); LOG_INFO << "captured waypoint " << (grid_.index() + 1) << "/" << grid_.size();
else else
LOG_INFO << "captured (directed)"; LOG_INFO << "captured (directed)";
} else if (since_settled < settle_wait_ms_ + frame_timeout_ms_) {
return; // no fresh frame yet; retry next tick
} else {
// Never park on one waypoint: a stalled camera would otherwise stop the
// whole sweep rather than costing it a single image.
LOG_WARN << "no frame within " << frame_timeout_ms_ << " ms of settling; skipping "
<< (control_code_ == 0 ? "waypoint " + std::to_string(grid_.index() + 1) +
"/" + std::to_string(grid_.size())
: std::string("directed capture"));
}
moving_ = false; moving_ = false;
settled_at_ms_ = 0;
if (control_code_ == 0) grid_.advance(); if (control_code_ == 0) grid_.advance();
resetTimer(); resetTimer();
} }
}
}
} // namespace fgc } // namespace fgc

View File

@ -166,28 +166,11 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
} }
// On-camera imaging + encoding. See CameraConfig for why the defaults are conservative. // On-camera imaging + encoding. See CameraConfig for why the defaults are conservative.
cfg.camera.binning = getInt(kv, "Camera.binning", cfg.camera.binning); cfg.camera.mode_dir = get(kv, "Camera.mode_dir", cfg.camera.mode_dir);
cfg.camera.offset_x = getInt(kv, "Camera.offset_x", cfg.camera.offset_x); cfg.camera.mode = get(kv, "Camera.mode", cfg.camera.mode);
cfg.camera.offset_y = getInt(kv, "Camera.offset_y", cfg.camera.offset_y);
cfg.camera.width = getInt(kv, "Camera.width", cfg.camera.width);
cfg.camera.height = getInt(kv, "Camera.height", cfg.camera.height);
cfg.camera.pixel_format = get(kv, "Camera.pixel_format", cfg.camera.pixel_format);
cfg.camera.throughput_mbytes = getInt(kv, "Camera.throughput_mbytes", cfg.camera.throughput_mbytes);
cfg.camera.stream_fps = getDouble(kv, "Camera.stream_fps", cfg.camera.stream_fps);
cfg.camera.exposure_auto = getBool(kv, "Camera.exposure_auto", cfg.camera.exposure_auto);
cfg.camera.exposure_max_us = getDouble(kv, "Camera.exposure_max_us", cfg.camera.exposure_max_us);
cfg.camera.gain_auto = getBool(kv, "Camera.gain_auto", cfg.camera.gain_auto);
cfg.camera.gain_max_db = getDouble(kv, "Camera.gain_max_db", cfg.camera.gain_max_db);
cfg.camera.white_balance_auto = getBool(kv, "Camera.white_balance_auto", cfg.camera.white_balance_auto);
cfg.camera.jxl_distance = getDouble(kv, "Camera.jxl_distance", cfg.camera.jxl_distance); cfg.camera.jxl_distance = getDouble(kv, "Camera.jxl_distance", cfg.camera.jxl_distance);
cfg.camera.jxl_effort = getInt(kv, "Camera.jxl_effort", cfg.camera.jxl_effort); cfg.camera.jxl_effort = getInt(kv, "Camera.jxl_effort", cfg.camera.jxl_effort);
cfg.camera.rotation_deg = getInt(kv, "Camera.rotation_deg", cfg.camera.rotation_deg); cfg.camera.rotation_deg = getInt(kv, "Camera.rotation_deg", cfg.camera.rotation_deg);
if (cfg.camera.binning < 1)
throw std::runtime_error("Camera.binning must be >= 1");
if (cfg.camera.throughput_mbytes <= 0)
throw std::runtime_error("Camera.throughput_mbytes must be > 0");
if (cfg.camera.stream_fps <= 0.0)
throw std::runtime_error("Camera.stream_fps must be > 0");
if (cfg.camera.jxl_distance < 0.0) if (cfg.camera.jxl_distance < 0.0)
throw std::runtime_error("Camera.jxl_distance must be >= 0 (0 = lossless)"); throw std::runtime_error("Camera.jxl_distance must be >= 0 (0 = lossless)");
if (cfg.camera.jxl_effort < 1 || cfg.camera.jxl_effort > 9) if (cfg.camera.jxl_effort < 1 || cfg.camera.jxl_effort > 9)
@ -196,53 +179,6 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
cfg.camera.rotation_deg != 180 && cfg.camera.rotation_deg != 270) cfg.camera.rotation_deg != 180 && cfg.camera.rotation_deg != 270)
throw std::runtime_error("Camera.rotation_deg must be 0, 90, 180 or 270"); throw std::runtime_error("Camera.rotation_deg must be 0, 90, 180 or 270");
// [Capture] acquisition mode + quality gate.
cfg.capture.mode = get(kv, "Capture.mode", cfg.capture.mode);
cfg.capture.quality_gate = getBool(kv, "Capture.quality_gate", cfg.capture.quality_gate);
cfg.capture.max_attempts = getInt(kv, "Capture.max_attempts", cfg.capture.max_attempts);
cfg.capture.min_attempts = getInt(kv, "Capture.min_attempts", cfg.capture.min_attempts);
cfg.capture.acquire_timeout_ms =
getInt(kv, "Capture.acquire_timeout_ms", cfg.capture.acquire_timeout_ms);
cfg.capture.settle_delay_ms = getInt(kv, "Capture.settle_delay_ms", cfg.capture.settle_delay_ms);
cfg.capture.target_mean = getDouble(kv, "Capture.target_mean", cfg.capture.target_mean);
cfg.capture.mean_tolerance = getDouble(kv, "Capture.mean_tolerance", cfg.capture.mean_tolerance);
cfg.capture.clip_max_fraction =
getDouble(kv, "Capture.clip_max_fraction", cfg.capture.clip_max_fraction);
cfg.capture.exposure_min_us = getDouble(kv, "Capture.exposure_min_us", cfg.capture.exposure_min_us);
cfg.capture.damping = getDouble(kv, "Capture.damping", cfg.capture.damping);
cfg.capture.default_exposure_us =
getDouble(kv, "Capture.default_exposure_us", cfg.capture.default_exposure_us);
cfg.capture.default_gain_db = getDouble(kv, "Capture.default_gain_db", cfg.capture.default_gain_db);
cfg.capture.blur_relative_floor =
getDouble(kv, "Capture.blur_relative_floor", cfg.capture.blur_relative_floor);
cfg.capture.blur_absolute_floor =
getDouble(kv, "Capture.blur_absolute_floor", cfg.capture.blur_absolute_floor);
cfg.capture.metric_stride = getInt(kv, "Capture.metric_stride", cfg.capture.metric_stride);
cfg.capture.sharpness_roi_px = getInt(kv, "Capture.sharpness_roi_px", cfg.capture.sharpness_roi_px);
cfg.capture.angle_quantum_deg =
getDouble(kv, "Capture.angle_quantum_deg", cfg.capture.angle_quantum_deg);
cfg.capture.store_stale_s = getInt(kv, "Capture.store_stale_s",
static_cast<int>(cfg.capture.store_stale_s));
if (cfg.capture.mode != "trigger" && cfg.capture.mode != "freerun")
throw std::runtime_error("Capture.mode must be 'trigger' or 'freerun', got '" +
cfg.capture.mode + "'");
if (cfg.capture.max_attempts < 1)
throw std::runtime_error("Capture.max_attempts must be >= 1");
if (cfg.capture.min_attempts < 1 || cfg.capture.min_attempts > cfg.capture.max_attempts)
throw std::runtime_error("Capture.min_attempts must be in 1..max_attempts");
if (cfg.capture.target_mean <= 0.0 || cfg.capture.target_mean >= 255.0)
throw std::runtime_error("Capture.target_mean must be in 0..255");
if (cfg.capture.clip_max_fraction < 0.0 || cfg.capture.clip_max_fraction > 1.0)
throw std::runtime_error("Capture.clip_max_fraction must be in 0..1");
if (cfg.capture.damping <= 0.0 || cfg.capture.damping > 1.0)
throw std::runtime_error("Capture.damping must be in (0..1]");
if (cfg.capture.metric_stride < 1)
throw std::runtime_error("Capture.metric_stride must be >= 1");
std::string store = get(kv, "Capture.exposure_store");
cfg.capture.exposure_store =
store.empty() ? paths::defaultExposureStore() : paths::expandUser(store);
std::string out = get(kv, "Paths.output_dir"); std::string out = get(kv, "Paths.output_dir");
cfg.paths.output_dir = out.empty() ? paths::defaultOutputDir() : paths::expandUser(out); cfg.paths.output_dir = out.empty() ? paths::defaultOutputDir() : paths::expandUser(out);
@ -348,6 +284,12 @@ AppConfig ConfigLoader::loadFromFile(const std::string& path) {
if (gf.is_relative()) gf = std::filesystem::path(path).parent_path() / gf; if (gf.is_relative()) gf = std::filesystem::path(path).parent_path() / gf;
cfg.scan.grid_file = gf.lexically_normal().string(); cfg.scan.grid_file = gf.lexically_normal().string();
} }
// Same for the camera mode directory.
{
std::filesystem::path md(paths::expandUser(cfg.camera.mode_dir));
if (md.is_relative()) md = std::filesystem::path(path).parent_path() / md;
cfg.camera.mode_dir = md.lexically_normal().string();
}
return cfg; return cfg;
} }

View File

@ -1,125 +0,0 @@
#include "fgc/ExposurePolicy.h"
#include <algorithm>
#include <cmath>
namespace fgc {
namespace {
// Gain is logarithmic; work in linear multiplier space when splitting a brightness
// correction between exposure time and gain.
inline double dbToLinear(double db) { return std::pow(10.0, db / 20.0); }
inline double linearToDb(double x) { return 20.0 * std::log10(std::max(x, 1e-9)); }
// Split a desired brightness multiplier across exposure and gain.
//
// Brightening spends exposure FIRST and reaches for gain only once exposure is
// capped: gain buys brightness at the cost of noise. Darkening does the reverse -
// surrender gain first, for the same reason. The exposure ceiling is also the
// motion-blur budget, so it is never exceeded to chase brightness.
CaptureSettings applyRatio(const CaptureSettings& cur, double ratio, const PolicyParams& p) {
CaptureSettings out = cur;
if (ratio >= 1.0) {
const double room = p.exposure_max_us / std::max(cur.exposure_us, 1e-6);
const double used = std::min(ratio, room);
out.exposure_us = std::clamp(cur.exposure_us * used, p.exposure_min_us, p.exposure_max_us);
const double residual = ratio / std::max(used, 1e-9);
if (residual > 1.0)
out.gain_db = std::min(p.gain_max_db, linearToDb(dbToLinear(cur.gain_db) * residual));
} else {
const double gain_lin = dbToLinear(cur.gain_db);
const double gain_room = 1.0 / std::max(gain_lin, 1e-9); // how far gain can fall to 0 dB
const double used = std::max(ratio, gain_room);
out.gain_db = std::clamp(linearToDb(gain_lin * used), 0.0, p.gain_max_db);
const double residual = ratio / std::max(used, 1e-9);
if (residual < 1.0)
out.exposure_us =
std::clamp(cur.exposure_us * residual, p.exposure_min_us, p.exposure_max_us);
}
return out;
}
// True when a proposed correction would not meaningfully move the camera - i.e.
// we are pinned at a limit and further attempts are pointless.
bool sameSettings(const CaptureSettings& a, const CaptureSettings& b) {
return std::abs(a.exposure_us - b.exposure_us) < 1.0 && std::abs(a.gain_db - b.gain_db) < 0.05;
}
} // namespace
Verdict evaluate(const ImageMetrics& m, const CaptureSettings& current, const PolicyParams& p,
double reference_sharpness) {
Verdict v;
v.next = current;
if (!m.valid) {
v.accept = false;
v.reason = "invalid";
return v;
}
auto propose = [&](double ratio, const char* reason) {
const double damped = 1.0 + p.damping * (ratio - 1.0);
CaptureSettings next = applyRatio(current, damped, p);
if (sameSettings(next, current)) {
// Pinned at a limit: the camera cannot do better here, so accept what
// we have rather than burning the remaining attempts on no-op retries.
v.accept = true;
v.reason = "saturated";
v.next = current;
} else {
v.accept = false;
v.reason = reason;
v.next = next;
}
};
// 1. Blown highlights outrank everything: unrecoverable, and invisible in the mean.
if (m.clipped_fraction > p.clip_max_fraction) {
// The mean gives no usable step here (it can look fine while the sky burns),
// so back off by a fixed factor instead.
propose(0.7, "clipped");
return v;
}
// 2. Mean luma outside the acceptance band.
const double ratio = p.target_mean / std::max(m.mean_luma, 1.0);
if (m.mean_luma < p.target_mean - p.mean_tolerance) {
propose(ratio, "dark");
return v;
}
if (m.mean_luma > p.target_mean + p.mean_tolerance) {
propose(ratio, "bright");
return v;
}
// 3. Exposure is good. Blur is checked last and never changes the settings -
// a shake or an early trigger is fixed by reshooting, not by re-metering.
if (p.blur_absolute_floor > 0.0 && m.sharpness < p.blur_absolute_floor) {
v.accept = false;
v.reason = "blur";
return v;
}
if (reference_sharpness > 0.0 && m.sharpness < p.blur_relative_floor * reference_sharpness) {
v.accept = false;
v.reason = "blur";
return v;
}
v.accept = true;
v.reason = "ok";
return v;
}
double score(const ImageMetrics& m, const PolicyParams& p) {
if (!m.valid) return -1.0;
const double clip_penalty = m.clipped_fraction / std::max(p.clip_max_fraction, 1e-9);
const double mean_penalty =
std::abs(m.mean_luma - p.target_mean) / std::max(p.mean_tolerance, 1e-9);
// Sharpness is the thing we ultimately want; exposure error only discounts it,
// so a sharp well-exposed frame always beats a sharp blown-out one.
return m.sharpness / (1.0 + clip_penalty + mean_penalty);
}
} // namespace fgc

View File

@ -1,147 +0,0 @@
#include "fgc/ExposureStore.h"
#include "fgc/Logger.h"
#include <cmath>
#include <filesystem>
#include <fstream>
#include <sstream>
namespace fs = std::filesystem;
namespace fgc {
namespace {
constexpr const char* kHeader =
"key,yaw_deg,pitch_deg,exposure_us,gain_db,sharpness,mean_luma,timestamp_ms,attempts";
std::vector<std::string> splitCsv(const std::string& line) {
std::vector<std::string> out;
std::stringstream ss(line);
std::string field;
while (std::getline(ss, field, ',')) out.push_back(field);
return out;
}
} // namespace
ExposureStore::ExposureStore(std::string path, double quantum_deg, long long stale_s)
: path_(std::move(path)),
quantum_deg_(quantum_deg > 0.0 ? quantum_deg : 1.0),
stale_s_(stale_s) {}
std::string ExposureStore::keyFor(double yaw_deg, double pitch_deg, double quantum_deg) {
const double q = quantum_deg > 0.0 ? quantum_deg : 1.0;
const long y = static_cast<long>(std::llround(yaw_deg / q));
const long p = static_cast<long>(std::llround(pitch_deg / q));
return "y" + std::to_string(y) + "_p" + std::to_string(p);
}
bool ExposureStore::load() {
entries_.clear();
std::ifstream in(path_);
if (!in) return false; // no file yet is normal on a first run
std::string line;
bool first = true;
int skipped = 0;
while (std::getline(in, line)) {
if (line.empty()) continue;
if (first) {
first = false;
if (line.rfind("key,", 0) == 0) continue; // header
}
auto f = splitCsv(line);
if (f.size() < 9) {
++skipped;
continue;
}
try {
StoreEntry e;
e.yaw_deg = std::stod(f[1]);
e.pitch_deg = std::stod(f[2]);
e.exposure_us = std::stod(f[3]);
e.gain_db = std::stod(f[4]);
e.sharpness = std::stod(f[5]);
e.mean_luma = std::stod(f[6]);
e.timestamp_ms = std::stoll(f[7]);
e.attempts = std::stoi(f[8]);
entries_[f[0]] = e;
} catch (const std::exception&) {
++skipped; // a bad row must never stop a scan
}
}
if (skipped > 0)
LOG_WARN << "exposure store: skipped " << skipped << " malformed row(s) in " << path_;
return true;
}
bool ExposureStore::save() const {
std::error_code ec;
const fs::path p(path_);
if (p.has_parent_path()) {
fs::create_directories(p.parent_path(), ec);
if (ec) {
LOG_WARN << "exposure store: cannot create " << p.parent_path().string() << ": "
<< ec.message();
return false;
}
}
std::ofstream out(path_, std::ios::trunc);
if (!out) {
LOG_WARN << "exposure store: cannot write " << path_;
return false;
}
out << kHeader << '\n';
for (const auto& [key, e] : entries_) {
out << key << ',' << e.yaw_deg << ',' << e.pitch_deg << ',' << e.exposure_us << ','
<< e.gain_db << ',' << e.sharpness << ',' << e.mean_luma << ',' << e.timestamp_ms << ','
<< e.attempts << '\n';
}
return out.good();
}
std::optional<StoreEntry> ExposureStore::find(double yaw_deg, double pitch_deg) const {
auto it = entries_.find(keyFor(yaw_deg, pitch_deg, quantum_deg_));
if (it == entries_.end()) return std::nullopt;
return it->second;
}
bool ExposureStore::fresh(const StoreEntry& e, long long now_ms) const {
if (e.timestamp_ms <= 0) return false;
return (now_ms - e.timestamp_ms) <= stale_s_ * 1000;
}
CaptureSettings ExposureStore::seed(double yaw_deg, double pitch_deg, long long now_ms,
const CaptureSettings& defaults) const {
if (auto e = find(yaw_deg, pitch_deg); e && fresh(*e, now_ms))
return CaptureSettings{e->exposure_us, e->gain_db};
if (last_accepted_ && (now_ms - last_accepted_ms_) <= stale_s_ * 1000) return *last_accepted_;
return defaults;
}
double ExposureStore::referenceSharpness(double yaw_deg, double pitch_deg, long long now_ms) const {
auto e = find(yaw_deg, pitch_deg);
if (!e || !fresh(*e, now_ms)) return 0.0;
return e->sharpness;
}
void ExposureStore::update(double yaw_deg, double pitch_deg, const CaptureSettings& settings,
const ImageMetrics& metrics, long long now_ms, int attempts) {
StoreEntry e;
e.yaw_deg = yaw_deg;
e.pitch_deg = pitch_deg;
e.exposure_us = settings.exposure_us;
e.gain_db = settings.gain_db;
e.sharpness = metrics.sharpness;
e.mean_luma = metrics.mean_luma;
e.timestamp_ms = now_ms;
e.attempts = attempts;
entries_[keyFor(yaw_deg, pitch_deg, quantum_deg_)] = e;
last_accepted_ = settings;
last_accepted_ms_ = now_ms;
}
} // namespace fgc

View File

@ -1,170 +0,0 @@
#include "fgc/GatedCameraSource.h"
#include "fgc/Logger.h"
#include <chrono>
#include <thread>
#include <utility>
namespace fgc {
namespace {
long long steadyNowMs() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
}
} // namespace
GatedCameraSource::GatedCameraSource(std::unique_ptr<ICameraSource> inner, ExposureStore* store,
Params params,
std::function<std::pair<double, double>()> angle,
std::function<long long()> now_ms,
std::function<void(int)> sleep_ms)
: inner_(std::move(inner)),
store_(store),
params_(std::move(params)),
angle_(std::move(angle)),
now_ms_(now_ms ? std::move(now_ms) : steadyNowMs),
sleep_ms_(sleep_ms ? std::move(sleep_ms)
: [](int ms) {
std::this_thread::sleep_for(std::chrono::milliseconds(ms));
}) {}
GatedCameraSource::~GatedCameraSource() = default;
void GatedCameraSource::open() { inner_->open(); }
void GatedCameraSource::close() { inner_->close(); }
void GatedCameraSource::start() {
inner_->start();
// The gate owns exposure and gain; the camera's own continuous auto would fight
// it (and is exactly the several-frames-to-converge cost we are avoiding).
if (params_.enabled) inner_->setAutoExposureGain(false);
}
void GatedCameraSource::stop() { inner_->stop(); }
bool GatedCameraSource::setFrameRate(double fps) { return inner_->setFrameRate(fps); }
void GatedCameraSource::setFrameCallback(FrameCallback cb) {
callback_ = std::move(cb);
// The inner source keeps its own callback for the ungated path; when gating is
// on we deliver the accepted frame ourselves instead.
inner_->setFrameCallback(callback_);
}
int GatedCameraSource::cameraCount() const { return inner_->cameraCount(); }
std::vector<CameraDeviceInfo> GatedCameraSource::deviceInfo() { return inner_->deviceInfo(); }
bool GatedCameraSource::acquireFrame(Frame& out, int t) { return inner_->acquireFrame(out, t); }
bool GatedCameraSource::setExposure(double us) { return inner_->setExposure(us); }
bool GatedCameraSource::setGain(double db) { return inner_->setGain(db); }
bool GatedCameraSource::setAutoExposureGain(bool on) { return inner_->setAutoExposureGain(on); }
double GatedCameraSource::currentGain() { return inner_->currentGain(); }
bool GatedCameraSource::trigger() {
if (!params_.enabled) return inner_->trigger();
const auto [yaw, pitch] = angle_ ? angle_() : std::make_pair(0.0, 0.0);
const long long now = now_ms_();
CaptureSettings settings = store_ ? store_->seed(yaw, pitch, now, params_.defaults)
: params_.defaults;
const double reference = store_ ? store_->referenceSharpness(yaw, pitch, now) : 0.0;
// Best attempt so far, by policy score. Kept so an exhausted budget still
// yields the least-bad frame rather than nothing.
bool have_best = false;
double best_score = -1.0;
Frame best_frame;
ImageMetrics best_metrics;
CaptureSettings best_settings;
std::string best_reason;
bool accepted = false;
int attempt = 0;
const int max_attempts = std::max(1, params_.max_attempts);
const int min_attempts = std::max(1, params_.min_attempts);
for (attempt = 1; attempt <= max_attempts; ++attempt) {
inner_->setExposure(settings.exposure_us);
inner_->setGain(settings.gain_db);
// Let the mechanics settle before the first shot: a trigger fired the
// instant the axes report standstill still catches the tail of the motion.
if (attempt == 1 && params_.settle_delay_ms > 0) sleep_ms_(params_.settle_delay_ms);
Frame f;
if (!inner_->acquireFrame(f, params_.acquire_timeout_ms)) {
LOG_WARN << "camera: acquisition failed on attempt " << attempt << '/' << max_attempts;
continue;
}
ImageMetrics m = analyzeFrame(f, params_.quality);
m.gain_db = inner_->currentGain();
const Verdict v = evaluate(m, settings, params_.policy, reference);
const double s = score(m, params_.policy);
if (!have_best || s > best_score) {
have_best = true;
best_score = s;
best_frame = f;
best_metrics = m;
best_settings = settings;
best_reason = v.reason;
}
LOG_TRACE_CAT(LogCat::Camera)
<< "capture attempt " << attempt << '/' << max_attempts << " mean=" << m.mean_luma
<< " clip=" << m.clipped_fraction << " sharp=" << m.sharpness << " gain=" << m.gain_db
<< " exp=" << settings.exposure_us << "us -> " << v.reason;
if (v.accept && attempt >= min_attempts) {
accepted = true;
break;
}
settings = v.next;
}
const int used = std::min(attempt, max_attempts);
last_report_ = CaptureReport{};
last_report_.attempts = used;
last_report_.yaw_deg = yaw;
last_report_.pitch_deg = pitch;
if (!have_best) {
LOG_WARN << "camera: no frame acquired at yaw " << yaw << " after " << used << " attempt(s)";
return false;
}
last_report_.captured = true;
last_report_.degraded = !accepted;
last_report_.reason = accepted ? best_reason : "exhausted";
last_report_.metrics = best_metrics;
last_report_.settings = best_settings;
if (!accepted) ++degraded_total_;
if (!accepted)
LOG_WARN << "camera: quality gate exhausted after " << used << " attempts at yaw " << yaw
<< " (mean=" << best_metrics.mean_luma << " clip=" << best_metrics.clipped_fraction
<< " sharp=" << best_metrics.sharpness << "); saving best effort";
else
LOG_DEBUG << "camera: accepted after " << used << " attempt(s) at yaw " << yaw
<< " (mean=" << best_metrics.mean_luma << " exp=" << best_settings.exposure_us
<< "us gain=" << best_settings.gain_db << "dB)";
if (store_) store_->update(yaw, pitch, best_settings, best_metrics, now_ms_(), used);
best_frame.degraded = !accepted;
if (callback_) callback_(best_frame);
return true;
}
} // namespace fgc

View File

@ -82,7 +82,10 @@ const std::vector<HelpSection>& helpCatalog() {
{"start", "Begin the capture scan.", {}}, {"start", "Begin the capture scan.", {}},
{"stop", "Halt the capture scan.", {}}, {"stop", "Halt the capture scan.", {}},
{"set fps <rate>", "Set capture rate in images/second.", {}}, {"set fps <rate>", "Set capture rate in images/second.", {}},
{"set camera fps <rate>", "Set the camera sensor frame rate.", {}}, {"set camera mode <name>", "Switch the camera mode (acquisition rate, exposure "
"limits, imaging settings).", {
"Modes are files in [Camera] mode_dir - one .ini per mode; the name is the",
"filename stem. Example: set camera mode night"}},
{"set camera jxlq <dist>", "Set JPEG-XL distance (lower = higher quality).", {}}, {"set camera jxlq <dist>", "Set JPEG-XL distance (lower = higher quality).", {}},
{"set camera jxle <effort>", "Set JPEG-XL encode effort.", {}}, {"set camera jxle <effort>", "Set JPEG-XL encode effort.", {}},
{"set camera display <0|1>", "Toggle the local display window.", {}}, {"set camera display <0|1>", "Toggle the local display window.", {}},

View File

@ -1,91 +0,0 @@
#include "fgc/ImageQuality.h"
#include <algorithm>
#include <cmath>
namespace fgc {
namespace {
// Rec.601 luma. Integer-free but cheap; the metrics are statistical, not exact.
inline double luma(const uint8_t* px, int channels) {
if (channels == 1) return px[0];
return 0.299 * px[0] + 0.587 * px[1] + 0.114 * px[2];
}
} // namespace
ImageMetrics analyzeFrame(const Frame& frame, const QualityParams& params) {
ImageMetrics m;
const int ch = frame.channels;
if ((ch != 1 && ch != 3) || frame.width == 0 || frame.height == 0) return m;
const size_t w = frame.width;
const size_t h = frame.height;
const size_t needed = w * h * static_cast<size_t>(ch);
if (frame.data.size() < needed) return m;
const uint8_t* base = frame.data.data();
const int stride = std::max(1, params.stride);
// --- Exposure / clipping / black, on the subsampled grid ---
double luma_sum = 0.0;
long long counted = 0, clipped = 0, dark = 0;
for (size_t y = 0; y < h; y += static_cast<size_t>(stride)) {
const uint8_t* row = base + y * w * static_cast<size_t>(ch);
for (size_t x = 0; x < w; x += static_cast<size_t>(stride)) {
const uint8_t* px = row + x * static_cast<size_t>(ch);
luma_sum += luma(px, ch);
++counted;
bool any_clipped = false, all_dark = true;
for (int c = 0; c < ch; ++c) {
if (px[c] >= params.clip_level) any_clipped = true;
if (px[c] > params.dark_level) all_dark = false;
}
if (any_clipped) ++clipped;
if (all_dark) ++dark;
}
}
if (counted == 0) return m;
m.valid = true;
m.mean_luma = luma_sum / static_cast<double>(counted);
m.clipped_fraction = static_cast<double>(clipped) / static_cast<double>(counted);
m.dark_fraction = static_cast<double>(dark) / static_cast<double>(counted);
// --- Sharpness: variance of the 3x3 Laplacian over a centre ROI, full res ---
// A blurred image has little high-frequency energy, so the Laplacian response
// clusters near zero and its variance collapses. The absolute value is scene
// dependent, which is why the policy compares it against the same angle's own
// history rather than a fixed threshold.
const size_t roi = std::min<size_t>({static_cast<size_t>(std::max(3, params.sharpness_roi_px)), w, h});
if (roi >= 3) {
const size_t x0 = (w - roi) / 2;
const size_t y0 = (h - roi) / 2;
double sum = 0.0, sum_sq = 0.0;
long long n = 0;
for (size_t y = y0 + 1; y < y0 + roi - 1; ++y) {
for (size_t x = x0 + 1; x < x0 + roi - 1; ++x) {
const uint8_t* c = base + (y * w + x) * static_cast<size_t>(ch);
const uint8_t* up = c - w * static_cast<size_t>(ch);
const uint8_t* dn = c + w * static_cast<size_t>(ch);
const uint8_t* lf = c - static_cast<size_t>(ch);
const uint8_t* rt = c + static_cast<size_t>(ch);
const double lap = luma(up, ch) + luma(dn, ch) + luma(lf, ch) + luma(rt, ch) -
4.0 * luma(c, ch);
sum += lap;
sum_sq += lap * lap;
++n;
}
}
if (n > 0) {
const double mean = sum / static_cast<double>(n);
m.sharpness = std::max(0.0, sum_sq / static_cast<double>(n) - mean * mean);
}
}
return m;
}
} // namespace fgc

View File

@ -102,12 +102,6 @@ std::string defaultLogDir() {
return (fs::path(base) / "fire_gimbal_control" / "logs").string(); return (fs::path(base) / "fire_gimbal_control" / "logs").string();
} }
std::string defaultExposureStore() {
// State, not a log, so it sits beside the logs directory rather than inside it.
std::string base = envOr("XDG_DATA_HOME", expandUser("~/.local/share"));
return (fs::path(base) / "fire_gimbal_control" / "exposure_store.csv").string();
}
std::string timestampedLogName(const std::string& prefix) { std::string timestampedLogName(const std::string& prefix) {
std::time_t t = std::time(nullptr); std::time_t t = std::time(nullptr);
std::tm tm{}; std::tm tm{};

View File

@ -120,8 +120,7 @@ void MqttControlChannel::publishCamEvent(const CamEvent& e) {
std::string payload = "{ \"fwt\":\"" + e.tower + "\" ,\"cam\":\"" + e.camera + std::string payload = "{ \"fwt\":\"" + e.tower + "\" ,\"cam\":\"" + e.camera +
"\", \"hdg\":" + std::to_string(e.heading_decideg) + "\", \"hdg\":" + std::to_string(e.heading_decideg) +
", \"pit\":" + std::to_string(e.pitch_decideg) + ", \"pit\":" + std::to_string(e.pitch_decideg) +
", \"time\":" + std::to_string(e.timestamp_ms) + ", \"time\":" + std::to_string(e.timestamp_ms) + " }";
", \"degraded\":" + (e.degraded ? "true" : "false") + " }";
publish(topic_cam_event_, payload); publish(topic_cam_event_, payload);
} }

View File

@ -208,10 +208,8 @@ Element cameraPanel(const CaptureView& c) {
rows.push_back(hbox({text("scan ") | dim, rows.push_back(hbox({text("scan ") | dim,
text(" GRID LOAD FAILED ") | color(Color::Red) | bold, text(" GRID LOAD FAILED ") | color(Color::Red) | bold,
text(" c for details") | dim})); text(" c for details") | dim}));
if (c.gate_active && c.gate_degraded) rows.push_back(hbox({text("mode ") | dim,
rows.push_back(hbox({text("quality ") | dim, text(c.config.mode.empty() ? "—" : c.config.mode) | color(Color::Cyan)}));
text(" DEGRADED ") | color(Color::Red) | bold,
text(" c for details") | dim}));
if (c.has_last) { if (c.has_last) {
long long now = std::chrono::duration_cast<std::chrono::milliseconds>( long long now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count(); std::chrono::system_clock::now().time_since_epoch()).count();
@ -249,29 +247,6 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
rows.push_back(row("images", text(std::to_string(c.images_saved) + " saved this session"))); rows.push_back(row("images", text(std::to_string(c.images_saved) + " saved this session")));
rows.push_back(row("output", text(c.config.output_dir.empty() ? "—" : c.config.output_dir) | dim)); rows.push_back(row("output", text(c.config.output_dir.empty() ? "—" : c.config.output_dir) | dim));
// --- Quality gate: how the last waypoint's image was judged ---
if (c.gate_active) {
rows.push_back(separator());
rows.push_back(text("QUALITY GATE") | bold | color(Color::Blue));
Element verdict = c.gate_degraded
? (text(" " + std::string("degraded") + " ") | color(Color::Red) | bold)
: (text(" " + (c.gate_reason.empty() ? "—" : c.gate_reason) + " ") |
color(Color::Green) | bold);
rows.push_back(hbox({text("last ") | dim, verdict, filler(),
text(std::to_string(c.gate_attempts) + " attempt(s)") | dim}));
rows.push_back(row("exposure", text(num(c.gate_exposure_us, 0) + " us gain " +
num(c.gate_gain_db, 1) + " dB")));
rows.push_back(row("brightness", text("mean " + num(c.gate_mean_luma, 1) + " clipped " +
num(c.gate_clipped_pct, 2) + " %")));
rows.push_back(row("sharpness", text(num(c.gate_sharpness, 0))));
// A rising degraded count is the signal that the thresholds or the exposure
// limits need revisiting, so it is worth a colour.
rows.push_back(row("degraded", text(std::to_string(c.gate_degraded_total) +
" this session") |
(c.gate_degraded_total ? color(Color::Yellow)
: color(Color::Default))));
}
// --- Devices: identity + live telemetry --- // --- Devices: identity + live telemetry ---
rows.push_back(separator()); rows.push_back(separator());
rows.push_back(text("DEVICES") | bold | color(Color::Blue)); rows.push_back(text("DEVICES") | bold | color(Color::Blue));
@ -289,7 +264,6 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
" " + num(d.payload_bytes / 1048576.0, 1) + " MB"))); " " + num(d.payload_bytes / 1048576.0, 1) + " MB")));
rows.push_back(row(" exposure", text(num(d.exposure_us, 0) + " us gain " + rows.push_back(row(" exposure", text(num(d.exposure_us, 0) + " us gain " +
num(d.gain_db, 1) + " dB"))); num(d.gain_db, 1) + " dB")));
rows.push_back(row(" white bal", text("R " + num(d.wb_red, 2) + " B " + num(d.wb_blue, 2))));
rows.push_back(row(" sensor", text(num(d.temperature_c, 1) + " °C " + rows.push_back(row(" sensor", text(num(d.temperature_c, 1) + " °C " +
num(d.actual_fps, 2) + " fps"))); num(d.actual_fps, 2) + " fps")));
rows.push_back(row(" frames", hbox({ rows.push_back(row(" frames", hbox({
@ -303,25 +277,17 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
// --- Imaging config --- // --- Imaging config ---
const CameraConfigView& cf = c.config; const CameraConfigView& cf = c.config;
rows.push_back(separator()); rows.push_back(separator());
rows.push_back(text("IMAGING CONFIG") | bold | color(Color::Blue)); rows.push_back(text("CAMERA MODE") | bold | color(Color::Blue));
rows.push_back(row("mode", text(cf.mock ? "MOCK (simulated)" : "real camera (Vimba X)"))); rows.push_back(row("driver", text(cf.mock ? "MOCK (simulated)" : "real camera (Vimba X)")));
rows.push_back(row("format", text(cf.pixel_format + " binning " + std::to_string(cf.binning) + "x"))); rows.push_back(row("mode", hbox({text(cf.mode.empty() ? "—" : cf.mode) | color(Color::Cyan) | bold,
rows.push_back(row("ROI", text((cf.width > 0 || cf.height > 0) text(" " + cf.mode_desc) | dim})));
? (std::to_string(cf.width) + "x" + std::to_string(cf.height) + rows.push_back(row("settle", text(std::to_string(cf.settle_wait_ms) +
" @ " + std::to_string(cf.offset_x) + "," + " ms after standstill before keeping a frame")));
std::to_string(cf.offset_y)) {
: "full sensor"))); std::string others;
rows.push_back(row("throughput", text(std::to_string(cf.throughput_mbytes) + " MB/s " + for (const auto& m : cf.modes_available) others += (others.empty() ? "" : ", ") + m;
"(stream " + num(cf.stream_fps, 1) + " fps)"))); rows.push_back(row("available", text(others.empty() ? "—" : others) | dim));
rows.push_back(row("exposure", text(!cf.exposure_auto ? std::string("manual") }
: cf.exposure_max_us > 0
? ("auto (max " + num(cf.exposure_max_us, 0) + " us)")
: "auto (no cap)")));
rows.push_back(row("gain", text(!cf.gain_auto ? std::string("manual")
: cf.gain_max_db > 0
? ("auto (max " + num(cf.gain_max_db, 1) + " dB)")
: "auto (no cap)")));
rows.push_back(row("white bal", text(cf.white_balance_auto ? "auto (continuous)" : "manual")));
rows.push_back(row("encoding", text("JPEG XL distance " + num(cf.jxl_distance, 2) + rows.push_back(row("encoding", text("JPEG XL distance " + num(cf.jxl_distance, 2) +
" effort " + std::to_string(cf.jxl_effort)))); " effort " + std::to_string(cf.jxl_effort))));
rows.push_back(row("rotation", text(cf.rotation_deg == 0 rows.push_back(row("rotation", text(cf.rotation_deg == 0

View File

@ -22,6 +22,7 @@ add_executable(fgc_tests
test_paths.cpp test_paths.cpp
test_singleinstance.cpp test_singleinstance.cpp
test_config.cpp test_config.cpp
test_cameramode.cpp
test_telemetry.cpp test_telemetry.cpp
test_command.cpp test_command.cpp
test_scheduler.cpp test_scheduler.cpp
@ -32,10 +33,6 @@ add_executable(fgc_tests
test_scroll.cpp test_scroll.cpp
test_mtiprotocol.cpp test_mtiprotocol.cpp
test_sht41.cpp test_sht41.cpp
test_imagequality.cpp
test_exposurepolicy.cpp
test_exposurestore.cpp
test_gatedcamera.cpp
test_dumpparser.cpp test_dumpparser.cpp
test_helptext.cpp test_helptext.cpp
test_diagparser.cpp test_diagparser.cpp

168
tests/test_cameramode.cpp Normal file
View File

@ -0,0 +1,168 @@
#include <doctest/doctest.h>
#include "fgc/CameraMode.h"
#include <filesystem>
#include <fstream>
#include <string>
#include <unistd.h>
using namespace fgc;
namespace fs = std::filesystem;
namespace {
// Each test gets its own directory so a stale file can never leak between them.
fs::path scratchDir(const std::string& tag) {
fs::path d = fs::temp_directory_path() / ("fgc_modes_" + tag + "_" + std::to_string(::getpid()));
fs::remove_all(d);
fs::create_directories(d);
return d;
}
void write(const fs::path& p, const std::string& text) {
std::ofstream f(p);
f << text;
}
const char* kMinimal = R"(
[Mode]
description = A mode
settle_wait_ms = 1500
frame_timeout_ms = 3000
[Camera]
AcquisitionFrameRate = 10
ExposureAuto = Continuous
)";
} // namespace
TEST_CASE("loadCameraMode reads the header and names the mode after the file") {
fs::path d = scratchDir("header");
write(d / "standard.ini", kMinimal);
CameraMode m = loadCameraMode((d / "standard.ini").string());
CHECK(m.name == "standard");
CHECK(m.description == "A mode");
CHECK(m.settle_wait_ms == 1500);
CHECK(m.frame_timeout_ms == 3000);
REQUIRE(m.features.size() == 2);
fs::remove_all(d);
}
TEST_CASE("loadCameraMode defaults the [Mode] header when it is absent") {
fs::path d = scratchDir("defaults");
write(d / "bare.ini", "[Camera]\nGamma = 1.0\n");
CameraMode m = loadCameraMode((d / "bare.ini").string());
CHECK(m.description.empty());
CHECK(m.settle_wait_ms == 1000);
CHECK(m.frame_timeout_ms == 2000);
fs::remove_all(d);
}
TEST_CASE("loadCameraMode preserves file order and repeated keys") {
// GenICam selectors are stateful, so both properties are load-bearing: a map
// would sort BalanceRatio ahead of BalanceRatioSelector and collapse the two
// ratios into one.
fs::path d = scratchDir("order");
write(d / "wb.ini", R"(
[Camera]
BalanceRatioSelector = Red
BalanceRatio = 2.87
BalanceRatioSelector = Blue
BalanceRatio = 2.0
)");
CameraMode m = loadCameraMode((d / "wb.ini").string());
REQUIRE(m.features.size() == 4);
CHECK(m.features[0] == std::make_pair(std::string("BalanceRatioSelector"), std::string("Red")));
CHECK(m.features[1] == std::make_pair(std::string("BalanceRatio"), std::string("2.87")));
CHECK(m.features[2] == std::make_pair(std::string("BalanceRatioSelector"), std::string("Blue")));
CHECK(m.features[3] == std::make_pair(std::string("BalanceRatio"), std::string("2.0")));
fs::remove_all(d);
}
TEST_CASE("loadCameraMode strips inline comments from feature values") {
fs::path d = scratchDir("comments");
write(d / "c.ini", "[Camera]\nExposureAutoMax = 20000 ; blur budget\n");
CameraMode m = loadCameraMode((d / "c.ini").string());
REQUIRE(m.features.size() == 1);
CHECK(m.features[0].second == "20000");
fs::remove_all(d);
}
TEST_CASE("loadCameraMode rejects files a typo would otherwise make silently wrong") {
fs::path d = scratchDir("bad");
write(d / "unknown_key.ini", "[Mode]\nsettle_wait = 500\n[Camera]\nGamma = 1\n");
CHECK_THROWS(loadCameraMode((d / "unknown_key.ini").string()));
write(d / "unknown_section.ini", "[Modes]\ndescription = x\n[Camera]\nGamma = 1\n");
CHECK_THROWS(loadCameraMode((d / "unknown_section.ini").string()));
write(d / "bad_value.ini", "[Mode]\nsettle_wait_ms = soon\n[Camera]\nGamma = 1\n");
CHECK_THROWS(loadCameraMode((d / "bad_value.ini").string()));
write(d / "negative.ini", "[Mode]\nsettle_wait_ms = -1\n[Camera]\nGamma = 1\n");
CHECK_THROWS(loadCameraMode((d / "negative.ini").string()));
// A mode that sets nothing on the camera is a mistake, not an empty mode.
write(d / "no_features.ini", "[Mode]\ndescription = nothing\n");
CHECK_THROWS(loadCameraMode((d / "no_features.ini").string()));
CHECK_THROWS(loadCameraMode((d / "does_not_exist.ini").string()));
fs::remove_all(d);
}
TEST_CASE("loadCameraModes picks up every *.ini in the directory") {
fs::path d = scratchDir("dir");
write(d / "standard.ini", kMinimal);
write(d / "night.ini", kMinimal);
write(d / "notes.txt", "not a mode");
auto modes = loadCameraModes(d.string());
CHECK(modes.size() == 2);
CHECK(modes.count("standard") == 1);
CHECK(modes.count("night") == 1);
CHECK(modes.count("notes") == 0);
fs::remove_all(d);
}
TEST_CASE("loadCameraModes fails loudly on an empty or missing directory") {
fs::path d = scratchDir("empty");
CHECK_THROWS(loadCameraModes(d.string()));
CHECK_THROWS(loadCameraModes((d / "nope").string()));
// One bad file fails the whole load, so a mode nobody uses today still cannot
// rot unnoticed until the night someone switches to it.
write(d / "good.ini", kMinimal);
write(d / "bad.ini", "[Mode]\nnonsense = 1\n");
CHECK_THROWS(loadCameraModes(d.string()));
fs::remove_all(d);
}
TEST_CASE("the shipped RGB mode files parse and define both modes") {
// Guards the real config/camera_modes/rgb files, which the rig depends on.
for (const char* rel : {"config/camera_modes/rgb", "../config/camera_modes/rgb"}) {
if (!fs::is_directory(rel)) continue;
auto modes = loadCameraModes(rel);
REQUIRE(modes.count("standard") == 1);
REQUIRE(modes.count("night") == 1);
CHECK(modes.at("standard").settle_wait_ms == 1000);
CHECK(modes.at("night").settle_wait_ms == 2500);
CHECK(modes.at("standard").features.size() > 40);
return;
}
MESSAGE("shipped mode files not reachable from the test working directory; skipped");
}

View File

@ -60,53 +60,6 @@ TEST_CASE("ConfigLoader maps [Env]/env feature flags") {
CHECK(c.env.period_ms == 5000); CHECK(c.env.period_ms == 5000);
} }
TEST_CASE("ConfigLoader maps [Capture] and defaults to a gated trigger") {
AppConfig d = ConfigLoader::fromMap({});
CHECK(d.capture.mode == "trigger");
CHECK(d.capture.quality_gate == true);
CHECK(d.capture.max_attempts == 3);
CHECK(d.capture.min_attempts == 1);
CHECK(d.capture.target_mean == doctest::Approx(110.0));
CHECK(d.capture.blur_relative_floor == doctest::Approx(0.5));
CHECK(d.capture.blur_absolute_floor == doctest::Approx(0.0)); // backstop off by default
CHECK_FALSE(d.capture.exposure_store.empty()); // resolved to a default path
AppConfig c = ConfigLoader::fromMap({
{"Capture.mode", "freerun"},
{"Capture.quality_gate", "false"},
{"Capture.max_attempts", "5"},
{"Capture.min_attempts", "2"},
{"Capture.target_mean", "128"},
{"Capture.clip_max_fraction", "0.01"},
{"Capture.exposure_store", "/tmp/fgc_store.csv"},
{"Capture.store_stale_s", "600"},
});
CHECK(c.capture.mode == "freerun");
CHECK(c.capture.quality_gate == false);
CHECK(c.capture.max_attempts == 5);
CHECK(c.capture.min_attempts == 2);
CHECK(c.capture.target_mean == doctest::Approx(128.0));
CHECK(c.capture.clip_max_fraction == doctest::Approx(0.01));
CHECK(c.capture.exposure_store == "/tmp/fgc_store.csv");
CHECK(c.capture.store_stale_s == 600);
}
TEST_CASE("Capture config rejects settings that could not work") {
// A typo'd mode would silently pick one of the two acquisition paths.
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.mode", "triggered"}}));
// min > max would make the gate unable to ever accept.
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.max_attempts", "2"},
{"Capture.min_attempts", "3"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.max_attempts", "0"}}));
// A target outside the pixel range can never be reached.
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.target_mean", "300"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.clip_max_fraction", "1.5"}}));
// Damping of 0 would freeze the correction; above 1 would overshoot.
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.damping", "0"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.damping", "1.5"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.metric_stride", "0"}}));
}
TEST_CASE("Env.i2c_addr accepts the hex form every datasheet uses") { TEST_CASE("Env.i2c_addr accepts the hex form every datasheet uses") {
// Plain std::stoi() stops at the 'x' and returns 0, which would silently // Plain std::stoi() stops at the 'x' and returns 0, which would silently
// configure address 0x00 — the sensor would then never answer. // configure address 0x00 — the sensor would then never answer.
@ -131,56 +84,32 @@ TEST_CASE("ConfigLoader validates input") {
CHECK_THROWS(ConfigLoader::fromMap({{"General.debug", "maybe"}})); CHECK_THROWS(ConfigLoader::fromMap({{"General.debug", "maybe"}}));
} }
TEST_CASE("Camera imaging + encoding config: defaults and overrides") { TEST_CASE("Camera mode selection + encoding config: defaults and overrides") {
// Defaults chosen for reliable transfer on the LattePanda USB3 host + near-lossless output.
AppConfig d = ConfigLoader::fromMap({}); AppConfig d = ConfigLoader::fromMap({});
CHECK(d.camera.binning == 2); CHECK(d.camera.mode == "standard");
CHECK(d.camera.pixel_format == "RGB8"); CHECK(d.camera.mode_dir.find("config/camera_modes/rgb") != std::string::npos);
CHECK(d.camera.throughput_mbytes == 250);
CHECK(d.camera.stream_fps == doctest::Approx(1.0));
CHECK(d.camera.exposure_auto == true);
CHECK(d.camera.gain_auto == true);
CHECK(d.camera.white_balance_auto == true);
CHECK(d.camera.jxl_distance == doctest::Approx(0.8)); CHECK(d.camera.jxl_distance == doctest::Approx(0.8));
CHECK(d.camera.jxl_effort == 4); CHECK(d.camera.jxl_effort == 4);
CHECK(d.camera.rotation_deg == 0); CHECK(d.camera.rotation_deg == 0);
AppConfig c = ConfigLoader::fromMap({ AppConfig c = ConfigLoader::fromMap({
{"Camera.binning", "1"}, {"Camera.mode_dir", "/tmp/modes"},
{"Camera.pixel_format", "BayerRG8"}, {"Camera.mode", "night"},
{"Camera.width", "2256"},
{"Camera.height", "2256"},
{"Camera.throughput_mbytes", "300"},
{"Camera.stream_fps", "1.5"},
{"Camera.exposure_auto", "false"},
{"Camera.exposure_max_us", "20000"},
{"Camera.gain_auto", "false"},
{"Camera.gain_max_db", "12"},
{"Camera.white_balance_auto", "false"},
{"Camera.jxl_distance", "0"}, {"Camera.jxl_distance", "0"},
{"Camera.jxl_effort", "6"}, {"Camera.jxl_effort", "6"},
{"Camera.rotation_deg", "180"}, {"Camera.rotation_deg", "180"},
}); });
CHECK(c.camera.binning == 1); CHECK(c.camera.mode_dir == "/tmp/modes");
CHECK(c.camera.pixel_format == "BayerRG8"); CHECK(c.camera.mode == "night");
CHECK(c.camera.width == 2256);
CHECK(c.camera.height == 2256);
CHECK(c.camera.throughput_mbytes == 300);
CHECK(c.camera.stream_fps == doctest::Approx(1.5));
CHECK(c.camera.exposure_auto == false);
CHECK(c.camera.exposure_max_us == doctest::Approx(20000.0));
CHECK(c.camera.gain_auto == false);
CHECK(c.camera.gain_max_db == doctest::Approx(12.0));
CHECK(c.camera.white_balance_auto == false);
CHECK(c.camera.jxl_distance == doctest::Approx(0.0)); // lossless CHECK(c.camera.jxl_distance == doctest::Approx(0.0)); // lossless
CHECK(c.camera.jxl_effort == 6); CHECK(c.camera.jxl_effort == 6);
CHECK(c.camera.rotation_deg == 180); CHECK(c.camera.rotation_deg == 180);
} }
TEST_CASE("Camera config validates ranges") { TEST_CASE("Camera config validates ranges") {
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.binning", "0"}})); // mode_dir / mode are not validated here: a blank value falls back to the
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.throughput_mbytes", "0"}})); // default, and whether the mode file actually exists is checked at load time
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.stream_fps", "0"}})); // (loadCameraModes), which can say what IS available.
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_distance", "-1"}})); CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_distance", "-1"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_effort", "0"}})); // effort 1..9 CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_effort", "0"}})); // effort 1..9
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_effort", "10"}})); CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_effort", "10"}}));

View File

@ -1,171 +0,0 @@
#include <doctest/doctest.h>
#include "fgc/ExposurePolicy.h"
#include <cmath>
using namespace fgc;
namespace {
PolicyParams params() {
PolicyParams p;
p.target_mean = 110.0;
p.mean_tolerance = 12.0;
p.clip_max_fraction = 0.005;
p.exposure_min_us = 50.0;
p.exposure_max_us = 20000.0;
p.gain_max_db = 12.0;
p.damping = 0.8;
return p;
}
ImageMetrics metrics(double mean, double clipped = 0.0, double sharpness = 1000.0) {
ImageMetrics m;
m.valid = true;
m.mean_luma = mean;
m.clipped_fraction = clipped;
m.sharpness = sharpness;
return m;
}
} // namespace
TEST_CASE("a well-exposed frame is accepted unchanged") {
auto v = evaluate(metrics(110.0), {5000.0, 0.0}, params());
CHECK(v.accept);
CHECK(v.reason == "ok");
// Anywhere inside the tolerance band is good enough - chasing the exact target
// would cost extra frames for no visible gain.
CHECK(evaluate(metrics(100.0), {5000.0, 0.0}, params()).accept);
CHECK(evaluate(metrics(120.0), {5000.0, 0.0}, params()).accept);
}
TEST_CASE("underexposure raises exposure before it reaches for gain") {
auto v = evaluate(metrics(40.0), {5000.0, 0.0}, params());
CHECK_FALSE(v.accept);
CHECK(v.reason == "dark");
CHECK(v.next.exposure_us > 5000.0);
CHECK(v.next.gain_db == doctest::Approx(0.0)); // gain costs noise; it waits
}
TEST_CASE("gain is only used once exposure is capped") {
PolicyParams p = params();
// Already at the exposure ceiling (which is also the motion-blur budget), so
// the only way to brighten further is gain.
auto v = evaluate(metrics(40.0), {p.exposure_max_us, 0.0}, p);
CHECK_FALSE(v.accept);
CHECK(v.next.exposure_us == doctest::Approx(p.exposure_max_us));
CHECK(v.next.gain_db > 0.0);
CHECK(v.next.gain_db <= p.gain_max_db);
}
TEST_CASE("overexposure surrenders gain before it shortens exposure") {
auto v = evaluate(metrics(200.0), {5000.0, 6.0}, params());
CHECK_FALSE(v.accept);
CHECK(v.reason == "bright");
CHECK(v.next.gain_db < 6.0);
// Exposure is untouched while there is still gain to give back.
CHECK(v.next.exposure_us == doctest::Approx(5000.0));
}
TEST_CASE("clipping outranks the mean") {
// The mean sits right on target, but the sky is blown. Blown highlights are
// unrecoverable, so this must still be rejected and darkened.
auto v = evaluate(metrics(110.0, /*clipped=*/0.05), {5000.0, 0.0}, params());
CHECK_FALSE(v.accept);
CHECK(v.reason == "clipped");
CHECK(v.next.exposure_us < 5000.0);
}
TEST_CASE("corrections stay inside the configured limits") {
PolicyParams p = params();
// Extreme darkness cannot push exposure past the blur budget or gain past its cap.
CaptureSettings s{p.exposure_max_us, p.gain_max_db};
auto v = evaluate(metrics(1.0), s, p);
CHECK(v.next.exposure_us <= p.exposure_max_us);
CHECK(v.next.gain_db <= p.gain_max_db);
// Extreme brightness cannot drive exposure below the floor or gain negative.
CaptureSettings s2{p.exposure_min_us, 0.0};
auto v2 = evaluate(metrics(254.0, 0.9), s2, p);
CHECK(v2.next.exposure_us >= p.exposure_min_us);
CHECK(v2.next.gain_db >= 0.0);
}
TEST_CASE("a camera pinned at its limits accepts instead of retrying forever") {
PolicyParams p = params();
// Still too dark, but exposure and gain are both maxed: no correction exists,
// so burning the remaining attempts would be pointless.
auto v = evaluate(metrics(40.0), {p.exposure_max_us, p.gain_max_db}, p);
CHECK(v.accept);
CHECK(v.reason == "saturated");
}
TEST_CASE("repeated correction converges and does not oscillate") {
PolicyParams p = params();
CaptureSettings s{1000.0, 0.0};
// Model a linear sensor: mean is proportional to exposure x gain.
auto simulate = [](const CaptureSettings& cs) {
const double lin = cs.exposure_us * std::pow(10.0, cs.gain_db / 20.0);
return std::min(255.0, lin * 0.02); // 5500 us -> 110
};
int iterations = 0;
bool converged = false;
for (; iterations < 12; ++iterations) {
auto v = evaluate(metrics(simulate(s)), s, p);
if (v.accept) {
converged = true;
break;
}
s = v.next;
}
CHECK(converged);
CHECK(iterations <= 6); // damping trades a little speed for stability
CHECK(simulate(s) == doctest::Approx(p.target_mean).epsilon(0.15));
}
TEST_CASE("blur is judged against this angle's own history, not an absolute number") {
PolicyParams p = params();
// Exposure is fine and there is no reference yet: nothing to compare against,
// so a low-detail scene (fog) must NOT be rejected.
CHECK(evaluate(metrics(110.0, 0.0, /*sharpness=*/5.0), {5000.0, 0.0}, p).accept);
// With a reference from a previous good frame here, a sudden collapse in
// sharpness is a shake or an early trigger - reject and reshoot.
auto v = evaluate(metrics(110.0, 0.0, 100.0), {5000.0, 0.0}, p, /*reference=*/1000.0);
CHECK_FALSE(v.accept);
CHECK(v.reason == "blur");
// Blur never re-meters: reshooting is the fix, not a different exposure.
CHECK(v.next.exposure_us == doctest::Approx(5000.0));
CHECK(v.next.gain_db == doctest::Approx(0.0));
// A modest drop is normal scene variation and stays acceptable.
CHECK(evaluate(metrics(110.0, 0.0, 900.0), {5000.0, 0.0}, p, 1000.0).accept);
}
TEST_CASE("the absolute blur backstop is off unless configured") {
PolicyParams p = params();
CHECK(evaluate(metrics(110.0, 0.0, 0.1), {5000.0, 0.0}, p).accept);
p.blur_absolute_floor = 50.0;
CHECK_FALSE(evaluate(metrics(110.0, 0.0, 0.1), {5000.0, 0.0}, p).accept);
}
TEST_CASE("score ranks sharpness but discounts exposure error") {
PolicyParams p = params();
// Between equally sharp frames, the better-exposed one wins.
CHECK(score(metrics(110.0, 0.0, 1000.0), p) > score(metrics(200.0, 0.0, 1000.0), p));
// Between equally exposed frames, the sharper one wins.
CHECK(score(metrics(110.0, 0.0, 2000.0), p) > score(metrics(110.0, 0.0, 1000.0), p));
// A blown frame is heavily penalised even when it is sharp.
CHECK(score(metrics(110.0, 0.5, 2000.0), p) < score(metrics(110.0, 0.0, 1000.0), p));
// An unusable frame never wins.
CHECK(score(ImageMetrics{}, p) < 0.0);
}

View File

@ -1,124 +0,0 @@
#include <doctest/doctest.h>
#include "fgc/ExposureStore.h"
#include <cstdio>
#include <filesystem>
#include <fstream>
using namespace fgc;
namespace fs = std::filesystem;
namespace {
std::string tempPath(const char* name) {
return (fs::temp_directory_path() / name).string();
}
ImageMetrics metrics(double sharpness, double mean = 110.0) {
ImageMetrics m;
m.valid = true;
m.sharpness = sharpness;
m.mean_luma = mean;
return m;
}
constexpr long long kNow = 1'700'000'000'000LL;
} // namespace
TEST_CASE("keyFor buckets angles so nearby headings share a seed") {
CHECK(ExposureStore::keyFor(137.2, -5.1, 1.0) == ExposureStore::keyFor(137.4, -4.9, 1.0));
CHECK(ExposureStore::keyFor(137.0, 0.0, 1.0) != ExposureStore::keyFor(138.0, 0.0, 1.0));
// A coarser quantum merges more headings into one bucket.
CHECK(ExposureStore::keyFor(10.0, 0.0, 5.0) == ExposureStore::keyFor(12.0, 0.0, 5.0));
// Negative pitch must round consistently, not toward zero.
CHECK(ExposureStore::keyFor(0.0, -7.6, 1.0) == ExposureStore::keyFor(0.0, -7.5, 1.0));
}
TEST_CASE("store round-trips through CSV") {
const std::string path = tempPath("fgc_test_exposure_roundtrip.csv");
fs::remove(path);
{
ExposureStore s(path);
s.update(90.0, 0.0, {4200.0, 3.5}, metrics(1234.0, 108.0), kNow, 2);
s.update(180.0, -5.0, {6000.0, 0.0}, metrics(900.0), kNow, 1);
REQUIRE(s.save());
}
ExposureStore loaded(path);
REQUIRE(loaded.load());
CHECK(loaded.size() == 2);
auto e = loaded.find(90.0, 0.0);
REQUIRE(e.has_value());
CHECK(e->exposure_us == doctest::Approx(4200.0));
CHECK(e->gain_db == doctest::Approx(3.5));
CHECK(e->sharpness == doctest::Approx(1234.0));
CHECK(e->attempts == 2);
CHECK(e->timestamp_ms == kNow);
fs::remove(path);
}
TEST_CASE("a missing store is an empty store, not an error path") {
ExposureStore s(tempPath("fgc_test_exposure_absent.csv"));
fs::remove(s.path());
CHECK_FALSE(s.load()); // reports "nothing loaded"...
CHECK(s.size() == 0); // ...but is perfectly usable
CHECK_FALSE(s.find(0.0, 0.0).has_value());
}
TEST_CASE("a malformed row is skipped rather than failing the whole load") {
// A corrupt line must never stop a scan from running.
const std::string path = tempPath("fgc_test_exposure_corrupt.csv");
{
std::ofstream out(path, std::ios::trunc);
out << "key,yaw_deg,pitch_deg,exposure_us,gain_db,sharpness,mean_luma,timestamp_ms,attempts\n";
out << "y90_p0,90,0,4200,3.5,1234,108," << kNow << ",2\n";
out << "garbage,not,a,number,at,all,here,either,x\n";
out << "too,few,fields\n";
out << "y180_p0,180,0,6000,0,900,110," << kNow << ",1\n";
}
ExposureStore s(path);
REQUIRE(s.load());
CHECK(s.size() == 2);
CHECK(s.find(90.0, 0.0).has_value());
CHECK(s.find(180.0, 0.0).has_value());
fs::remove(path);
}
TEST_CASE("seeding falls through three tiers as entries go stale") {
const CaptureSettings defaults{9999.0, 9.0};
ExposureStore s(tempPath("fgc_test_exposure_seed.csv"), 1.0, /*stale_s=*/1800);
// Tier 3: nothing known at all.
CHECK(s.seed(90.0, 0.0, kNow, defaults).exposure_us == doctest::Approx(9999.0));
s.update(90.0, 0.0, {4200.0, 1.0}, metrics(1000.0), kNow, 1);
// Tier 1: this angle's own fresh entry is the best predictor.
CHECK(s.seed(90.0, 0.0, kNow + 1000, defaults).exposure_us == doctest::Approx(4200.0));
// Tier 2: the angle's entry has gone stale, but something was accepted recently
// elsewhere - light changes affect all angles together, so that beats both the
// stale entry and the cold defaults.
s.update(200.0, 0.0, {5500.0, 2.0}, metrics(800.0), kNow + 3600'000, 1);
auto seeded = s.seed(90.0, 0.0, kNow + 3600'000, defaults);
CHECK(seeded.exposure_us == doctest::Approx(5500.0));
CHECK(seeded.gain_db == doctest::Approx(2.0));
// Everything stale: fall all the way back to the configured defaults.
CHECK(s.seed(90.0, 0.0, kNow + 100'000'000, defaults).exposure_us == doctest::Approx(9999.0));
}
TEST_CASE("reference sharpness is only offered while it is still current") {
ExposureStore s(tempPath("fgc_test_exposure_ref.csv"), 1.0, /*stale_s=*/1800);
CHECK(s.referenceSharpness(90.0, 0.0, kNow) == doctest::Approx(0.0));
s.update(90.0, 0.0, {4200.0, 0.0}, metrics(1500.0), kNow, 1);
CHECK(s.referenceSharpness(90.0, 0.0, kNow + 1000) == doctest::Approx(1500.0));
// Too old to compare against: the scene may have changed character entirely
// (fog rolling in), so returning 0 disables the relative blur check.
CHECK(s.referenceSharpness(90.0, 0.0, kNow + 100'000'000) == doctest::Approx(0.0));
}

View File

@ -1,254 +0,0 @@
#include <doctest/doctest.h>
#include "fgc/GatedCameraSource.h"
#include <algorithm>
#include <cmath>
#include <vector>
using namespace fgc;
namespace {
// A fake camera whose frames are synthesised from whatever exposure/gain the gate
// asks for, so the whole feedback loop can be exercised without hardware.
class FakeCamera : public ICameraSource {
public:
// Sensor model: mean luma is proportional to exposure x gain, saturating at 255.
double sensitivity = 0.02; // 5500 us at 0 dB -> ~110
double sharpness_fill = 0; // 0 = checkerboard detail; >0 = flat (blurred)
int fail_first_n = 0; // acquisitions that fail outright
bool clip_always = false;
// Per-attempt sharpness overrides, consumed in order; empty = always sharp.
std::vector<double> scripted_blur;
int acquisitions = 0;
std::vector<double> exposures_seen;
bool auto_disabled = false;
bool started = false;
void open() override {}
void close() override {}
void start() override { started = true; }
void stop() override { started = false; }
bool trigger() override { return false; } // never used when gating is on
void setFrameCallback(FrameCallback cb) override { cb_ = std::move(cb); }
int cameraCount() const override { return 1; }
bool setExposure(double us) override {
exposure_ = us;
exposures_seen.push_back(us);
return true;
}
bool setGain(double db) override {
gain_ = db;
return true;
}
bool setAutoExposureGain(bool on) override {
auto_disabled = !on;
return true;
}
double currentGain() override { return gain_; }
bool acquireFrame(Frame& out, int) override {
if (acquisitions++ < fail_first_n) return false;
const double lin = exposure_ * std::pow(10.0, gain_ / 20.0);
const double mean = std::min(250.0, lin * sensitivity);
// Blur is modelled by flattening the image: a flat field has no
// second-derivative energy, so its sharpness collapses.
double blur = sharpness_fill;
if (!scripted_blur.empty()) {
blur = scripted_blur.front();
scripted_blur.erase(scripted_blur.begin());
}
const uint32_t w = 64, h = 64;
out = Frame{};
out.width = w;
out.height = h;
out.channels = 1;
out.timestamp_ms = 1000 + acquisitions;
out.data.assign(static_cast<size_t>(w) * h, 0);
for (uint32_t y = 0; y < h; ++y) {
for (uint32_t x = 0; x < w; ++x) {
double v = mean;
if (blur <= 0.0) {
// Zero-mean detail on a period of 3. A period-2 checkerboard
// would alias against the stride-4 metric sampler (which only
// ever hits one phase) and skew the mean by the full amplitude.
const int k = static_cast<int>((x + y) % 3);
v = mean + (k == 0 ? -40.0 : (k == 1 ? 0.0 : 40.0));
}
if (clip_always && x < w / 2) v = 255;
out.data[y * w + x] = static_cast<uint8_t>(std::clamp(v, 0.0, 255.0));
}
}
return true;
}
private:
double exposure_ = 1000.0;
double gain_ = 0.0;
FrameCallback cb_;
};
GatedCameraSource::Params params(int max_attempts = 3) {
GatedCameraSource::Params p;
p.max_attempts = max_attempts;
p.min_attempts = 1;
p.acquire_timeout_ms = 10;
p.settle_delay_ms = 0;
p.defaults = {1000.0, 0.0};
p.quality.sharpness_roi_px = 32;
p.policy.target_mean = 110.0;
p.policy.mean_tolerance = 12.0;
p.policy.exposure_max_us = 20000.0;
p.policy.gain_max_db = 12.0;
return p;
}
// Build a gate over a fake camera, capturing delivered frames.
struct Rig {
FakeCamera* cam;
std::unique_ptr<GatedCameraSource> gate;
std::vector<Frame> delivered;
ExposureStore store{"", 1.0, 1800};
explicit Rig(GatedCameraSource::Params p, bool with_store = false) {
auto owned = std::make_unique<FakeCamera>();
cam = owned.get();
gate = std::make_unique<GatedCameraSource>(
std::move(owned), with_store ? &store : nullptr, p,
[] { return std::make_pair(90.0, 0.0); }, [] { return 1'700'000'000'000LL; },
[](int) {});
gate->setFrameCallback([this](const Frame& f) { delivered.push_back(f); });
}
};
} // namespace
TEST_CASE("a good first frame is kept without extra acquisitions") {
Rig r(params());
r.cam->sensitivity = 0.11; // 1000 us default already lands on ~110
CHECK(r.gate->trigger());
CHECK(r.cam->acquisitions == 1);
REQUIRE(r.delivered.size() == 1);
CHECK_FALSE(r.delivered[0].degraded);
CHECK(r.gate->lastReport().attempts == 1);
CHECK(r.gate->lastReport().reason == "ok");
CHECK_FALSE(r.gate->lastReport().degraded);
}
TEST_CASE("an underexposed frame is corrected and re-shot") {
Rig r(params());
r.cam->sensitivity = 0.02; // 1000 us -> mean 20: far too dark
CHECK(r.gate->trigger());
CHECK(r.cam->acquisitions > 1);
REQUIRE(r.delivered.size() == 1);
CHECK_FALSE(r.delivered[0].degraded);
// Exposure was actually raised on the camera between attempts.
REQUIRE(r.cam->exposures_seen.size() >= 2);
CHECK(r.cam->exposures_seen[1] > r.cam->exposures_seen[0]);
}
TEST_CASE("an exhausted budget still delivers the best attempt, flagged degraded") {
auto p = params(/*max_attempts=*/2);
Rig r(p);
r.cam->clip_always = true; // half the frame is blown no matter what we do
CHECK(r.gate->trigger());
CHECK(r.cam->acquisitions == 2);
REQUIRE(r.delivered.size() == 1);
CHECK(r.delivered[0].degraded); // never lose a waypoint, but mark it
CHECK(r.gate->lastReport().degraded);
CHECK(r.gate->lastReport().reason == "exhausted");
CHECK(r.gate->lastReport().attempts == 2);
}
TEST_CASE("total acquisition failure delivers nothing and reports it") {
auto p = params(/*max_attempts=*/2);
Rig r(p);
r.cam->fail_first_n = 99;
CHECK_FALSE(r.gate->trigger());
CHECK(r.delivered.empty());
CHECK_FALSE(r.gate->lastReport().captured);
}
TEST_CASE("a transient acquisition failure does not lose the waypoint") {
Rig r(params());
r.cam->sensitivity = 0.11;
r.cam->fail_first_n = 1; // first attempt drops, second succeeds
CHECK(r.gate->trigger());
REQUIRE(r.delivered.size() == 1);
CHECK_FALSE(r.delivered[0].degraded);
}
TEST_CASE("min_attempts always shoots extra and keeps the sharpest") {
auto p = params(/*max_attempts=*/3);
p.min_attempts = 2;
Rig r(p);
r.cam->sensitivity = 0.11; // exposure is fine from the start
// First frame blurred, second sharp: with min_attempts=2 the gate must take
// both and keep the better one, which is the point of the setting.
r.cam->scripted_blur = {1.0, 0.0};
CHECK(r.gate->trigger());
CHECK(r.cam->acquisitions == 2);
REQUIRE(r.delivered.size() == 1);
CHECK(r.gate->lastReport().metrics.sharpness > 0.0);
CHECK_FALSE(r.delivered[0].degraded);
}
TEST_CASE("the gate takes exposure control away from the camera on start") {
Rig r(params());
r.gate->start();
CHECK(r.cam->started);
// The camera's own continuous auto would fight the gate, and its convergence
// cost is exactly what the per-angle store exists to avoid.
CHECK(r.cam->auto_disabled);
}
TEST_CASE("disabling the gate passes straight through to the inner camera") {
auto p = params();
p.enabled = false;
Rig r(p);
CHECK_FALSE(r.gate->trigger()); // FakeCamera::trigger() is a no-op
CHECK(r.cam->acquisitions == 0);
CHECK(r.delivered.empty());
}
TEST_CASE("accepted settings are written back to the store for the next visit") {
Rig r(params(), /*with_store=*/true);
r.cam->sensitivity = 0.02;
CHECK(r.gate->trigger());
auto e = r.store.find(90.0, 0.0);
REQUIRE(e.has_value());
CHECK(e->exposure_us > 1000.0); // the corrected value, not the cold default
CHECK(e->sharpness > 0.0);
CHECK(e->attempts >= 1);
}
TEST_CASE("a seeded angle converges in one attempt on the next visit") {
Rig r(params(), /*with_store=*/true);
r.cam->sensitivity = 0.02;
CHECK(r.gate->trigger());
const int first_sweep = r.cam->acquisitions;
CHECK(first_sweep > 1); // had to search for the right exposure
r.cam->acquisitions = 0;
CHECK(r.gate->trigger());
// Second visit starts from the stored setting, so no search is needed. This is
// the whole point of the per-angle memory.
CHECK(r.cam->acquisitions == 1);
}

View File

@ -1,114 +0,0 @@
#include <doctest/doctest.h>
#include "fgc/ImageQuality.h"
using namespace fgc;
namespace {
// A frame filled with one constant value.
Frame flat(uint32_t w, uint32_t h, int channels, uint8_t value) {
Frame f;
f.width = w;
f.height = h;
f.channels = channels;
f.data.assign(static_cast<size_t>(w) * h * channels, value);
return f;
}
// A 1-pixel checkerboard: maximum high-frequency content, so maximum sharpness.
Frame checkerboard(uint32_t w, uint32_t h) {
Frame f = flat(w, h, 1, 0);
for (uint32_t y = 0; y < h; ++y)
for (uint32_t x = 0; x < w; ++x) f.data[y * w + x] = ((x + y) % 2) ? 255 : 0;
return f;
}
// A smooth horizontal ramp: plenty of contrast, but almost no second derivative.
Frame ramp(uint32_t w, uint32_t h) {
Frame f = flat(w, h, 1, 0);
for (uint32_t y = 0; y < h; ++y)
for (uint32_t x = 0; x < w; ++x)
f.data[y * w + x] = static_cast<uint8_t>((x * 255) / (w - 1));
return f;
}
} // namespace
TEST_CASE("analyzeFrame rejects frames it cannot interpret") {
CHECK_FALSE(analyzeFrame(Frame{}).valid);
Frame bad_channels = flat(8, 8, 2, 50);
CHECK_FALSE(analyzeFrame(bad_channels).valid);
// Buffer shorter than width*height*channels must not be read past its end.
Frame truncated = flat(64, 64, 3, 50);
truncated.data.resize(100);
CHECK_FALSE(analyzeFrame(truncated).valid);
}
TEST_CASE("analyzeFrame measures mean luma for mono and RGB") {
ImageMetrics mono = analyzeFrame(flat(64, 64, 1, 128));
REQUIRE(mono.valid);
CHECK(mono.mean_luma == doctest::Approx(128.0));
CHECK(mono.clipped_fraction == doctest::Approx(0.0));
CHECK(mono.dark_fraction == doctest::Approx(0.0));
// Equal R=G=B weights sum to 1.0, so grey RGB gives the same luma.
ImageMetrics rgb = analyzeFrame(flat(64, 64, 3, 128));
REQUIRE(rgb.valid);
CHECK(rgb.mean_luma == doctest::Approx(128.0));
}
TEST_CASE("analyzeFrame detects blown highlights and crushed blacks") {
ImageMetrics blown = analyzeFrame(flat(64, 64, 3, 255));
REQUIRE(blown.valid);
CHECK(blown.clipped_fraction == doctest::Approx(1.0));
CHECK(blown.dark_fraction == doctest::Approx(0.0));
ImageMetrics black = analyzeFrame(flat(64, 64, 3, 0));
REQUIRE(black.valid);
CHECK(black.dark_fraction == doctest::Approx(1.0));
CHECK(black.clipped_fraction == doctest::Approx(0.0));
// A single blown channel is enough to count the pixel as clipped - important
// for RGB, where a red sunset can saturate one channel while the mean is fine.
Frame one_channel = flat(64, 64, 3, 100);
for (size_t i = 0; i < one_channel.data.size(); i += 3) one_channel.data[i] = 255;
ImageMetrics m = analyzeFrame(one_channel);
CHECK(m.clipped_fraction == doctest::Approx(1.0));
}
TEST_CASE("sharpness separates detailed from smooth images") {
QualityParams p;
p.sharpness_roi_px = 64;
const double checker = analyzeFrame(checkerboard(128, 128), p).sharpness;
const double smooth = analyzeFrame(ramp(128, 128), p).sharpness;
const double blank = analyzeFrame(flat(128, 128, 1, 128), p).sharpness;
// A flat field has no second derivative at all.
CHECK(blank == doctest::Approx(0.0));
// A ramp has strong contrast but is locally linear, so it is nearly as flat -
// this is why sharpness must not be inferred from contrast or stddev.
CHECK(smooth < checker / 100.0);
CHECK(checker > 1000.0);
}
TEST_CASE("stride subsampling agrees with a full scan") {
// Half the frame blown, half mid-grey: any correct sampling sees ~50%.
Frame f = flat(128, 128, 1, 128);
for (uint32_t y = 0; y < 64; ++y)
for (uint32_t x = 0; x < 128; ++x) f.data[y * 128 + x] = 255;
QualityParams full;
full.stride = 1;
QualityParams strided;
strided.stride = 4;
ImageMetrics a = analyzeFrame(f, full);
ImageMetrics b = analyzeFrame(f, strided);
CHECK(b.mean_luma == doctest::Approx(a.mean_luma).epsilon(0.02));
CHECK(b.clipped_fraction == doctest::Approx(a.clipped_fraction).epsilon(0.02));
CHECK(b.clipped_fraction == doctest::Approx(0.5).epsilon(0.02));
}

View File

@ -78,6 +78,7 @@ TEST_CASE("CaptureScheduler sweeps the scan grid with MOVE + settle + trigger")
CaptureScheduler sch(motor, cam, chan, 1.0 /*img/s -> 1000ms*/, testGeometry(), grid, CaptureScheduler sch(motor, cam, chan, 1.0 /*img/s -> 1000ms*/, testGeometry(), grid,
[&] { return clock; }); [&] { return clock; });
sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test
sch.setCaptureActive(true); sch.setCaptureActive(true);
chan.next.control_code_available = true; chan.next.control_code_available = true;
@ -100,7 +101,9 @@ TEST_CASE("CaptureScheduler sweeps the scan grid with MOVE + settle + trigger")
// Report standstill at the target -> camera fires, cursor advances. // Report standstill at the target -> camera fires, cursor advances.
settleAt(motor, 0, 0); settleAt(motor, 0, 0);
clock = 1700; clock = 1700;
sch.tick(); sch.tick(); // first tick records standstill
clock = 1710;
sch.tick(); // next one keeps the frame
CHECK(cam.triggers == 1); CHECK(cam.triggers == 1);
CHECK_FALSE(sch.moving()); CHECK_FALSE(sch.moving());
@ -119,6 +122,7 @@ TEST_CASE("CaptureScheduler scans a 1-axis (yaw-only) rig with yaw-only MOVE/set
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}}); ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; }); CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test
sch.setCaptureActive(true); sch.setCaptureActive(true);
chan.next.control_code_available = true; chan.next.control_code_available = true;
chan.next.control_code = 0; chan.next.control_code = 0;
@ -135,6 +139,8 @@ TEST_CASE("CaptureScheduler scans a 1-axis (yaw-only) rig with yaw-only MOVE/set
motor.tel.yaw.state = AxisState::Ready; motor.tel.yaw.state = AxisState::Ready;
clock = 1700; clock = 1700;
sch.tick(); sch.tick();
clock = 1710;
sch.tick();
CHECK(cam.triggers == 1); CHECK(cam.triggers == 1);
clock = 2800; clock = 2800;
@ -149,6 +155,7 @@ TEST_CASE("CaptureScheduler ControlCode 1 drives yaw to the target heading") {
FakeChannel chan; FakeChannel chan;
ScanGrid grid({{0.0, 0.0}}); ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; }); CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test
sch.setCaptureActive(true); sch.setCaptureActive(true);
chan.next.control_code_available = true; chan.next.control_code_available = true;
@ -170,6 +177,7 @@ TEST_CASE("CaptureScheduler clamps the target heading to the soft range") {
FakeChannel chan; FakeChannel chan;
ScanGrid grid({{0.0, 0.0}}); ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; }); CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test
sch.setCaptureActive(true); sch.setCaptureActive(true);
chan.next.control_code_available = true; chan.next.control_code_available = true;
@ -191,6 +199,7 @@ TEST_CASE("CaptureScheduler::setGeometry re-targets with the recalibrated conver
FakeChannel chan; FakeChannel chan;
ScanGrid grid({{0.0, 0.0}}); ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; }); CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test
sch.setCaptureActive(true); sch.setCaptureActive(true);
chan.next.control_code_available = true; chan.next.control_code_available = true;
@ -205,7 +214,9 @@ TEST_CASE("CaptureScheduler::setGeometry re-targets with the recalibrated conver
// Settle + trigger so the scheduler is ready to move again. // Settle + trigger so the scheduler is ready to move again.
settleAt(motor, 450, 0); settleAt(motor, 450, 0);
clock = 1700; clock = 1700;
sch.tick(); sch.tick(); // records standstill
clock = 1710;
sch.tick(); // keeps the frame
// Recalibrate to 20 counts/deg; the same persisted heading must now resolve // Recalibrate to 20 counts/deg; the same persisted heading must now resolve
// to twice the counts — no new heading sent. // to twice the counts — no new heading sent.
@ -233,3 +244,69 @@ TEST_CASE("CaptureScheduler stays idle when capture inactive") {
CHECK(motor.cmds.empty()); CHECK(motor.cmds.empty());
CHECK(cam.triggers == 0); CHECK(cam.triggers == 0);
} }
TEST_CASE("CaptureScheduler holds still for settle_wait_ms before keeping a frame") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(1000, 2000);
sch.setCaptureActive(true);
chan.next.control_code_available = true;
chan.next.control_code = 0;
clock = 1600;
sch.tick();
REQUIRE(motor.cmds.size() == 1);
// Standstill starts the wait; the camera is not touched during it.
settleAt(motor, 0, 0);
clock = 2000;
sch.tick();
CHECK(cam.triggers == 0);
clock = 2900; // 900 ms after settling: still inside the wait
sch.tick();
CHECK(cam.triggers == 0);
// Past the wait, the newest frame is kept and the sweep advances.
clock = 3100;
sch.tick();
CHECK(cam.triggers == 1);
CHECK_FALSE(sch.moving());
}
TEST_CASE("CaptureScheduler abandons a waypoint after frame_timeout_ms") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
cam.ok = false; // camera never has a fresh frame
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(1000, 2000);
sch.setCaptureActive(true);
chan.next.control_code_available = true;
chan.next.control_code = 0;
clock = 1600;
sch.tick();
settleAt(motor, 0, 0);
clock = 1700;
sch.tick();
// Retries through the timeout window without giving up.
clock = 3500; // 1800 ms after settling: past the wait, inside the timeout
sch.tick();
CHECK(sch.moving());
CHECK(grid.index() == 0);
// Past wait + timeout, the waypoint is skipped rather than blocking the sweep.
clock = 4800;
sch.tick();
CHECK_FALSE(sch.moving());
CHECK(grid.index() == 1);
}