Camera is now configured by per-mode files; quality gate removed
This commit is contained in:
parent
02d3f573cf
commit
f1342965de
|
|
@ -60,10 +60,7 @@ add_library(fgc_core STATIC
|
|||
src/core/Calibration.cpp
|
||||
src/core/CalibrationRoutine.cpp
|
||||
src/core/MtiProtocol.cpp
|
||||
src/core/ImageQuality.cpp
|
||||
src/core/ExposurePolicy.cpp
|
||||
src/core/ExposureStore.cpp
|
||||
src/core/GatedCameraSource.cpp
|
||||
src/core/CameraMode.cpp
|
||||
src/sensors/Sht41Protocol.cpp
|
||||
src/ui/UiSnapshot.cpp
|
||||
src/ui/Scroll.cpp
|
||||
|
|
|
|||
20
README.md
20
README.md
|
|
@ -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
|
||||
`/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
|
||||
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
|
||||
cut: the kernel drops the lock when the process dies. `--no-lock` bypasses it for a second mock-only
|
||||
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. |
|
||||
| `set fps <n>` | Capture rate, in images per second. |
|
||||
| `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 jxle <n>` | JPEG XL encoder effort (higher = slower, smaller). |
|
||||
| `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. |
|
||||
| `exit` | Stop everything and quit (Ctrl-D also works). |
|
||||
|
||||
**Image capture** is per-waypoint and self-correcting: at each settled waypoint the camera is
|
||||
software-triggered for one deliberate frame, which is then measured (brightness, blown highlights,
|
||||
sharpness) and re-shot with corrected exposure/gain until it passes. Accepted settings are remembered
|
||||
**per heading**, so the next visit starts from a value known to work there instead of waiting for the
|
||||
camera's own auto-exposure to re-converge. If the retry budget runs out the best attempt is kept and
|
||||
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
|
||||
**Image capture** runs the camera continuously and keeps one frame per waypoint. The camera free-runs
|
||||
at the active mode's rate with its own auto-exposure tracking the light; almost every frame is
|
||||
discarded, and their job is simply to keep that exposure controller converged. At each waypoint the
|
||||
gimbal settles, holds still for the mode's `settle_wait_ms`, and the newest frame is kept — so the saved
|
||||
image was exposed at rest and already correctly metered, with nothing waiting on a convergence loop.
|
||||
|
||||
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).
|
||||
|
||||
The **Xsens MTi IMU** (set `[Features] enable_imu` + `[IMU] device`) drives the Sensors panel's live
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -29,30 +29,18 @@ id_Cam1 = DEV_0000000000
|
|||
id_Cam2 =
|
||||
id_Cam3 =
|
||||
id_Cam4 =
|
||||
; --- On-camera imaging (Alvium; applied in-session at startup) ---
|
||||
; The LattePanda USB3 host cannot reliably transfer large single frames (~60 MB RGB8
|
||||
; full-res stalls); keep frames small and PACE capture. See docs/known-issues.md.
|
||||
; Digital binning: 1 = full res; 2 = 2x2 (~5 MP, ~15 MB) - the reliable default.
|
||||
binning = 2
|
||||
; ROI (0 = sensor maximum / full frame). Set width/height to crop.
|
||||
offset_x = 0
|
||||
offset_y = 0
|
||||
width = 0
|
||||
height = 0
|
||||
; Pixel format: RGB8 keeps de-Bayering AND white balance on-camera (colour out, no host debayer).
|
||||
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
|
||||
; --- Camera modes ---
|
||||
; Every on-camera setting (acquisition rate, exposure limits, binning, ROI, image
|
||||
; processing) lives in a mode file: a mode named X is <mode_dir>/X.ini. Nothing
|
||||
; about the sensor is configured here, so the two can never disagree.
|
||||
; A relative path resolves against the directory holding the config.ini IN USE -
|
||||
; which is the repo root on the device (deploy.sh copies this file there), not
|
||||
; this config/ directory.
|
||||
mode_dir = config/camera_modes/rgb
|
||||
; Which mode to start in. Switch live with `set camera mode <name>`.
|
||||
; Ships with: standard (10 Hz daytime), night (1 Hz, long exposures).
|
||||
mode = standard
|
||||
|
||||
; JPEG XL encoding. distance: 0 = lossless, ~0.8 = near-lossless (default); effort 1..9.
|
||||
jxl_distance = 0.8
|
||||
jxl_effort = 4
|
||||
|
|
@ -61,70 +49,6 @@ jxl_effort = 4
|
|||
; (Builds before this key existed always rotated 90 CCW, i.e. 270.)
|
||||
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]
|
||||
; Motor-controller serial device and baud rate.
|
||||
device = /dev/ttyACM0
|
||||
|
|
|
|||
56
deploy.sh
56
deploy.sh
|
|
@ -14,10 +14,17 @@
|
|||
# ./deploy.sh --status is it running on the device?
|
||||
# ./deploy.sh --stop shut the running app down cleanly
|
||||
# ./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 --run --force skip the placeholder-config guard before running
|
||||
# ./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
|
||||
# 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.
|
||||
|
|
@ -50,10 +57,12 @@ CHECK_DEPS=0
|
|||
FORCE=0
|
||||
HOME_INIT=0
|
||||
FOREGROUND=0
|
||||
PUSH_CONFIG=0
|
||||
SESSION_CMD=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--clean) CLEAN=1 ;;
|
||||
--push-config|--with-config) PUSH_CONFIG=1 ;;
|
||||
--run|--start) RUN=1 ;;
|
||||
--home|--init) HOME_INIT=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
|
||||
exit 1
|
||||
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
|
||||
# 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'"
|
||||
|
||||
# config.ini and images/ are device-local (runtime config + captured output) and
|
||||
# are deliberately NOT mirrored, so a deploy never clobbers them. build/ is
|
||||
# machine-specific and regenerated remotely.
|
||||
# are deliberately NOT mirrored, so a deploy never clobbers them. Note this
|
||||
# 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"
|
||||
rsync -avz --delete \
|
||||
--exclude 'build/' \
|
||||
--exclude 'build*/' \
|
||||
--exclude '.git' \
|
||||
--exclude '.venv' \
|
||||
--exclude '.vscode' \
|
||||
--exclude '__pycache__' \
|
||||
--exclude '.deploy.env' \
|
||||
--exclude 'config.ini' \
|
||||
"${CONFIG_EXCLUDES[@]}" \
|
||||
--exclude 'config.ini.bak-*' \
|
||||
--exclude 'images/' \
|
||||
./ "$REMOTE_HOST:$REMOTE_DIR/"
|
||||
|
||||
# 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.
|
||||
echo ">> ensuring remote config.ini exists"
|
||||
ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && \
|
||||
# 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"
|
||||
ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && \
|
||||
if [ ! -f config.ini ]; then \
|
||||
cp config/config.example.ini config.ini && \
|
||||
echo ' seeded config.ini from config/config.example.ini — edit it on the device'; \
|
||||
else echo ' keeping existing device config.ini'; fi"
|
||||
fi
|
||||
|
||||
if [[ "$CLEAN" == "1" ]]; then
|
||||
echo ">> wiping remote build dir"
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
| Image worker | `ImagePipeline` | drains the frame queue: rotate → encode → write → publish |
|
||||
| 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
|
||||
(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).
|
||||
3. **Control input** — `IControlChannel::poll()` returns the latest `ControlCommand` (control code + target
|
||||
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 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.
|
||||
5. **Acquisition + quality gate** (`GatedCameraSource`) — `trigger()` does not simply take a picture. It
|
||||
seeds exposure/gain from the per-angle store, software-triggers **one** frame, measures it, corrects and
|
||||
re-shoots until it passes or the budget runs out. See *Image capture* below.
|
||||
6. **Frame handling** — the accepted `Frame` goes to the callback, which `submit()`s it to the
|
||||
- Once **both axes report standstill at the target**, wait `settle_wait_ms`, then keep the newest
|
||||
frame and advance the grid.
|
||||
5. **Acquisition** — the camera free-runs at the active mode's `AcquisitionFrameRate` with its own
|
||||
auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image
|
||||
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
|
||||
`<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
|
||||
│ (both axes standstill AND |xenc − target| ≤ tol)
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ camera.trigger() │ on success: clear moving, advance grid (ControlCode 0)
|
||||
└──────────────────────┘ on failure: retry next tick, do NOT advance
|
||||
┌──────────────────────┐ the camera keeps streaming throughout; this pause is
|
||||
│ wait settle_wait_ms │ what makes the kept frame one exposed at rest
|
||||
└──────────┬───────────┘
|
||||
▼
|
||||
┌──────────────────────┐ 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
|
||||
|
|
@ -107,91 +112,71 @@ everything below is an implementation detail of the camera source. That is what
|
|||
|
||||
## 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
|
||||
("paced free-run + keep-latest"). Two problems followed. The frame saved at a waypoint was captured at some
|
||||
unknown moment *before* the axes finished settling, and its exposure had been metered for wherever the camera
|
||||
happened to be pointing at the time — so brightness swung wildly between waypoints as the horizon changed.
|
||||
And nothing checked that the saved image was usable at all.
|
||||
The camera **runs continuously** at the mode's frame rate with its own auto-exposure and auto-gain in
|
||||
`Continuous`. Almost every frame it produces is thrown away. Their job is not to be saved — it is to keep
|
||||
the camera's exposure controller converged on the current light, so that the one frame we do keep is
|
||||
already correct without anyone waiting for it.
|
||||
|
||||
The camera's own continuous auto-exposure cannot fix this: it needs **several frames to converge** every time
|
||||
the scene changes, and a scan changes scene at every waypoint. Waiting for it costs exactly the time the scan
|
||||
is trying to save.
|
||||
At each waypoint the scheduler therefore:
|
||||
|
||||
### 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.
|
||||
|
||||
```
|
||||
seed exposure/gain from the per-angle store
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ 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
|
||||
```
|
||||
If no fresh frame has arrived by `frame_timeout_ms` past the wait, the waypoint is logged and skipped. A
|
||||
stalled camera costs single images, never the whole sweep.
|
||||
|
||||
Layering, all behind `ICameraSource` so the scheduler is unaffected:
|
||||
### Camera modes
|
||||
|
||||
| Piece | Role |
|
||||
|---|---|
|
||||
| [`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 |
|
||||
| [`ExposurePolicy`](../include/fgc/ExposurePolicy.h) | Pure control law: verdict + corrected settings + a score for ranking attempts |
|
||||
| [`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 |
|
||||
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
|
||||
camera settings and no translation layer — a feature the camera exposes can be set by adding a line, and
|
||||
`Feature::GetDataType()` tells the applier whether to write it as an int, float, enum, string or bool.
|
||||
|
||||
Because the gate depends only on the interface plus pure modules, the whole retry loop is unit-tested against
|
||||
a scripted fake camera — no hardware, no OpenCV ([tests/test_gatedcamera.cpp](../tests/test_gatedcamera.cpp)).
|
||||
Two properties of the file are load-bearing, and both are why the mode loader does not reuse the ordinary
|
||||
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,
|
||||
and blown highlights are unrecoverable — a slightly dark frame is not. So clipping is checked first and
|
||||
forces exposure down regardless of the mean.
|
||||
A feature this camera does not expose is a warning, not a fatal error — that warning is the feedback loop
|
||||
for a hand-edited mode file. A *structural* error (unknown section, unknown `[Mode]` key, a mode with no
|
||||
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
|
||||
noise, so it is the last resort on the way up and the first thing surrendered on the way down.
|
||||
Ships with `standard` (10 Hz daytime) and `night` (1 Hz, long exposures, high gain ceiling). Switch live
|
||||
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
|
||||
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.
|
||||
### Why not judge each frame instead
|
||||
|
||||
**Blur is relative, never absolute.** A foggy or featureless horizon is legitimately low-detail; a fixed
|
||||
sharpness threshold would retry forever on it. Each frame is compared against *the same angle's own* recent
|
||||
sharpness, which is scene-independent: fog stays fog, but a gust or an early trigger shows as a sudden
|
||||
collapse. Blur never re-meters — reshooting is the fix, not a different exposure.
|
||||
An earlier design software-triggered one frame per waypoint, measured it (mean luma, clipping, sharpness),
|
||||
corrected exposure and re-shot until it passed, seeded from a per-angle exposure store. It was removed. It
|
||||
duplicated — and fought — the camera's own exposure controller, which meters continuously in hardware and
|
||||
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
|
||||
(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.
|
||||
### Bandwidth: why both modes bin 2×2
|
||||
|
||||
**A waypoint is never lost.** If the budget runs out, the best-scoring attempt is delivered anyway and marked
|
||||
`degraded` — in the log, in the TUI `c` panel, and on the MQTT `CamEvent` — so coverage stays complete while
|
||||
a degraded frame is never mistaken for a good one.
|
||||
At `PixelFormat = RGB8` a full-resolution 4512×4512 frame is **61 MB**. Under the 250 MB/s
|
||||
`DeviceLinkThroughputLimit` that caps out at ~4 fps, and even at the interface maximum only ~7 — so 10 Hz at
|
||||
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
|
||||
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).
|
||||
Full parameter reference: `[Camera]` in [configuration.md](configuration.md).
|
||||
|
||||
## Why this shape
|
||||
|
||||
|
|
|
|||
|
|
@ -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` | `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` | `binning` | int ≥ 1 | `2` | Digital binning; `1` = full res, `2` = 2×2 (~5 MP, ~15 MB — the reliable default) |
|
||||
| `Camera` | `offset_x`/`offset_y` | int | `0` | ROI origin (0 = full frame) |
|
||||
| `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` | `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` | `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` | `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` | `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` |
|
||||
| `Features` | `enable_mqtt` | bool | `true` | Use MQTT (vs null channel) |
|
||||
| `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
|
||||
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.
|
||||
With `mode = trigger` (the default) that is one deliberate `TriggerSoftware` frame, so the image
|
||||
belongs to the waypoint that asked for it — unlike the older `freerun` model, which streamed
|
||||
continuously and kept whichever frame happened to arrive last, metered for wherever the camera was
|
||||
pointing at the time.
|
||||
Every on-camera setting lives in a **mode file**, one per mode, at `<mode_dir>/<name>.ini`. The mode's
|
||||
name is the filename stem, so adding a mode is dropping in a file — there is no registry to keep in
|
||||
sync. `config.ini` only picks which one to start in; nothing about the sensor is configured in two
|
||||
places.
|
||||
|
||||
**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 |
|
||||
| Mean brightness (`target_mean` ± `mean_tolerance`) | `dark` / `bright` | Brightening spends **exposure first, gain last** (gain costs noise); darkening does the reverse |
|
||||
| Noise | — | Gated by the camera's reported gain against `[Camera] gain_max_db`, not estimated from pixels |
|
||||
| Sharpness | `blur` | Re-shoot; never re-meters |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
Exposure is clamped to `[Camera] exposure_max_us`, which therefore doubles as the **motion-blur
|
||||
budget** — a long exposure on a wind-loaded tower is itself a blur source. When the camera is pinned
|
||||
at its limits and still cannot reach the target, the verdict is `saturated` and the frame is
|
||||
accepted: retrying could not do better. When the budget runs out, the **best attempt is kept** and
|
||||
flagged `degraded` — in the log, in the TUI `c` panel, and on the MQTT `CamEvent` — so a waypoint is
|
||||
never lost but a degraded image is never mistaken for a good one.
|
||||
**How a mode is applied.** A `[Camera]` block is a list of Vimba X features written verbatim, in file
|
||||
order, at start-up and on every switch. Names and enum values must be spelled exactly as VimbaX spells
|
||||
them (`Minimizenoise`, not `MinimizeNoise`); the easiest source of truth is a settings export from
|
||||
VimbaX itself. There is no fixed vocabulary — any feature the camera exposes can be set by adding a
|
||||
line, and the type is read off the camera.
|
||||
|
||||
**Blur is relative, not absolute.** A foggy or featureless horizon is legitimately low-detail, so a
|
||||
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.
|
||||
Order and repetition are deliberate and preserved:
|
||||
|
||||
**Per-angle memory.** Accepted settings are stored per quantised heading (`exposure_store`), so the
|
||||
next visit starts from a setting known to work there rather than paying the camera's own
|
||||
auto-exposure several frames to re-converge. 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 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.
|
||||
- **Order.** GenICam selectors are stateful, so a selector must precede what it selects, and binning
|
||||
must precede `Width`/`Height` because it changes their maxima.
|
||||
- **Repeats.** Writing both white-balance ratios requires `BalanceRatioSelector = Red`,
|
||||
`BalanceRatio = …`, `BalanceRatioSelector = Blue`, `BalanceRatio = …`.
|
||||
|
||||
White balance stays on the camera (`BalanceWhiteAuto`) even under manual exposure: it is far less
|
||||
time-critical and is correctable in post, unlike a blown or blurred frame.
|
||||
**Error handling.** A feature this camera does not expose logs `camera: no such feature '<name>'` and
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ scripts/fgc peek # snapshot of the pane without attaching
|
|||
right while attached.
|
||||
|
||||
`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.
|
||||
|
||||
`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
|
||||
|
||||
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
|
||||
|
||||
- `/run/user/<uid>/fire_gimbal_control.lock` when that directory exists, else
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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). |
|
||||
| 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 | **`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. |
|
||||
| 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
|
||||
|
|
@ -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.
|
||||
- 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
|
||||
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
|
||||
reduced resolution proves insufficient.
|
||||
|
||||
**Acquisition is now per-waypoint software trigger** (`[Capture] mode = trigger`), not the paced stream.
|
||||
Note what the measurements above do and do not show: they bound **frame size and pacing**, not trigger
|
||||
mode. The earlier conclusion that "software trigger stalls this host" was drawn from an implementation
|
||||
that triggered only `cameras[0]`, never set `TriggerSource`/`TriggerMode` at all, fired a blind 4×400 ms
|
||||
burst, and ran *before* the binning fix — i.e. it was pushing 61 MB frames down the path that stalls for
|
||||
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.
|
||||
**Acquisition is a continuous free-running stream** at the active mode's `AcquisitionFrameRate`; the
|
||||
scheduler stops at each waypoint, waits `settle_wait_ms`, and keeps the newest frame. This replaced a
|
||||
per-waypoint software trigger, which worked on the rig but existed to serve a quality gate that no longer
|
||||
does. Free-running is what keeps the camera's own auto-exposure converged, and the frames it discards
|
||||
between waypoints cost nothing the scan was going to use.
|
||||
|
||||
**Status (rig-tested 2026-08-07).** The `[Camera]`/`[Capture]` schema, in-session imaging config,
|
||||
near-lossless JPEG XL, the quality gate and unit tests are in place. On the LattePanda with the real Alvium:
|
||||
Note the bandwidth ceiling this implies: at 10 Hz, RGB8 and 2×2 binning, the stream is ~153 MB/s — safely
|
||||
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
|
||||
acquisition failures**, across four runs), which settles the load-bearing assumption behind `mode = trigger`.
|
||||
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.
|
||||
**Status (rig-tested 2026-08-07, before the mode rework).** The measurements below were taken with the
|
||||
older software-trigger path; they bound **frame size and pacing**, which the change does not alter.
|
||||
|
||||
**Not confirmed** — the lens was covered for the whole test, so every frame was black (mean ~1/255) and the
|
||||
gate could only ever report `saturated`. Everything that depends on seeing a real scene is still open: see
|
||||
**#15–#18** in *Open / needs hardware confirmation* above, plus **#19** for the USB preconditions.
|
||||
**Confirmed working** — a full sweep at 15/15 waypoints with **0 dropped, 0 stalls, 0 acquisition
|
||||
failures**, across four runs. Camera configuration was verified *independently* through Allied Vision's own
|
||||
`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 **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)
|
||||
|
||||
- **Spurious `could not set AcquisitionFrameRateEnable=false` warning** on every camera start.
|
||||
`AcquisitionFrameRateEnable` and `TriggerMode` lock each other: while `TriggerMode=On` the frame-rate
|
||||
control is read-only, and vice versa. Re-configuring an already-triggered camera therefore tries to write a
|
||||
locked feature that is *already* at the wanted value. All four state transitions were traced and the
|
||||
ordering in `configureCamera` is correct — this is cosmetic — but it is misleading noise that would mask a
|
||||
genuine failure of that constraint. Fix by skipping the write when the value already matches.
|
||||
- **Per-angle white balance.** WB is deliberately left on the camera (`BalanceWhiteAuto=Continuous`) since it
|
||||
is correctable in post; storing and seeding it per angle like exposure is the natural next step once
|
||||
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.
|
||||
- **A mode file cannot be reloaded without a restart.** `set camera mode <name>` re-applies a mode from the
|
||||
copy loaded at start-up, so editing a `.ini` while the program runs has no effect. Tuning on the rig
|
||||
therefore costs a restart per edit. Re-reading the file on switch would fix it, at the price of a failure
|
||||
mode (a mid-session syntax error) that start-up loading currently rules out.
|
||||
- **Nothing checks that a mode actually took.** Features are written and warned about individually, but the
|
||||
camera is never read back to confirm the resulting state, and a mode whose every write failed still logs
|
||||
as applied. A post-apply readback of a handful of key features (`AcquisitionFrameRate`, `Width`,
|
||||
`PixelFormat`) would turn a silent misconfiguration into an error.
|
||||
- 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
|
||||
settings are now config-driven under `[Camera]` — see [configuration.md](configuration.md).)
|
||||
- Make the camera index→label map fully config-driven. (JPEG XL distance/effort live under `[Camera]`, and
|
||||
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
|
||||
removed).
|
||||
- **`gimbal calib` persistence**: the fitted `counts_per_deg`/`zero_count` are always applied to the live
|
||||
|
|
|
|||
|
|
@ -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/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/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/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/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) |
|
||||
|
|
@ -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/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/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/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.
|
||||
|
||||
### `CamEvent` ([IControlChannel.h](../include/fgc/IControlChannel.h))
|
||||
`tower`, `camera` (RGB/ACR/NIR), `heading_decideg` (yaw×10), `pitch_decideg` (pitch×10), `timestamp_ms`,
|
||||
and `degraded` (the quality gate exhausted its attempts and kept its best effort).
|
||||
Serialized to the CamEvent JSON payload (see [mqtt-api.md](mqtt-api.md)).
|
||||
`tower`, `camera` (RGB/ACR/NIR), `heading_decideg` (yaw×10), `pitch_decideg` (pitch×10) and
|
||||
`timestamp_ms` (which matches the image filename). Serialized to the CamEvent JSON payload (see [mqtt-api.md](mqtt-api.md)).
|
||||
|
||||
### `EnvEvent` ([IControlChannel.h](../include/fgc/IControlChannel.h))
|
||||
`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.
|
||||
|
||||
### `Frame` ([ICameraSource.h](../include/fgc/ICameraSource.h))
|
||||
Owned pixel buffer + `width`, `height`, `channels` (1 or 3), `timestamp_ms`, `cam_id`, and `degraded`
|
||||
(carried through to the `CamEvent`).
|
||||
Owned pixel buffer + `width`, `height`, `channels` (1 or 3), `timestamp_ms`, `cam_id`.
|
||||
|
||||
### `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),
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ When a ControlCode message arrives, the program echoes the current code back on
|
|||
Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
|
||||
|
||||
```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 |
|
||||
|
|
@ -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 |
|
||||
| `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) |
|
||||
| `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
|
||||
> for a given event: `<RGB|ACR|NIR>/<time>.jxl`.
|
||||
|
|
|
|||
|
|
@ -28,15 +28,16 @@ Keep entries dated; move shipped items into the reference docs.
|
|||
re-engineer retained "last value" semantics). The abstraction seam already
|
||||
exists (`IControlChannel`). _(TODO: confirm approach, target release.)_
|
||||
- **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
|
||||
tuning against a real scene — see [known-issues.md](known-issues.md) #15–#18 —
|
||||
and the capture logic itself is described in
|
||||
[architecture.md](architecture.md#image-capture).
|
||||
- **Per-angle white balance.** Exposure and gain are already remembered per
|
||||
heading; WB is still left to the camera's continuous auto. Extending the store
|
||||
to WB ratios is the natural follow-on once exposure gating is proven on a lit
|
||||
scene. _(TODO: confirm it is worth the extra state.)_
|
||||
confirming the shipped `standard`/`night` modes against a real scene, and that
|
||||
10 Hz sustains with zero dropped frames on the LattePanda. The capture logic is
|
||||
described in [architecture.md](architecture.md#image-capture) and the modes in
|
||||
[configuration.md](configuration.md).
|
||||
- **A dusk/dawn mode, and automatic switching.** `standard` and `night` are
|
||||
chosen by hand (`set camera mode <name>`). A third mode for the transition, and
|
||||
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,
|
||||
temperature range); enables the `camera/thermal` self-test. _(TODO: model/SDK,
|
||||
calibration workflow.)_
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`ICameraSource`/`ImagePipeline`.
|
||||
|
||||
**The shared frame-quality core already exists.** `analyzeFrame()` in
|
||||
[ImageQuality.h](../include/fgc/ImageQuality.h) computes mean luma, clipped/dark fraction and a
|
||||
variance-of-Laplacian sharpness score over a raw buffer, with no OpenCV dependency, and is unit-tested
|
||||
against synthetic frames. The `camera / rgb` leaf should call it rather than reimplementing the histogram
|
||||
and sharpness checks below — it already covers "usable histogram (not clipped)" and "sharpness/focus
|
||||
metric". What it does **not** yet provide is dead/hot-pixel counting, realized-FPS measurement, timestamp
|
||||
monotonicity or encode-throughput timing.
|
||||
**The frame-quality helpers this leaf needs no longer exist.** `ImageQuality`/`ExposurePolicy` were
|
||||
removed with the capture quality gate (the camera's own auto-exposure does that job now), so the
|
||||
histogram and sharpness checks below have to be written for the self-test itself. That is the right home
|
||||
for them: a self-test measures and reports, where the gate tried to *act* on the same numbers in the
|
||||
capture path. Still missing either way: dead/hot-pixel counting, realized-FPS measurement, timestamp
|
||||
monotonicity and encode-throughput timing.
|
||||
|
||||
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
|
||||
blocked one. Note that a self-test asserting exposure health needs the lens uncovered and the limits in
|
||||
`[Camera]` tuned first — [known-issues.md](known-issues.md) #15–#18.
|
||||
[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 shipped
|
||||
camera modes tuned first — [known-issues.md](known-issues.md) #15–#16.
|
||||
|
||||
- **`camera / rgb`** — _Healthy:_ enumerates and connects; delivers frames at ~the
|
||||
configured rate; exposure/gain give a usable histogram (not clipped); few
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -18,13 +18,12 @@ namespace fgc {
|
|||
// 1. polls the control channel (updates control code / target heading,
|
||||
// echoes the code back as status),
|
||||
// 2. reads motor telemetry (per-axis encoder counts + state),
|
||||
// 3. runs the capture cycle as a move -> settle -> trigger machine: when 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
|
||||
// the camera for an image. Whether that is a single triggered frame, and
|
||||
// whether it is judged and re-shot until it passes, is decided below this
|
||||
// layer (see GatedCameraSource) - the scheduler only cares that trigger()
|
||||
// eventually succeeds, and retries next tick if it does not.
|
||||
// 3. runs the capture cycle as a move -> settle -> wait -> keep machine: when
|
||||
// the interval elapses it issues an absolute `MOVE <yaw>,<pitch>` to the next
|
||||
// target, waits until both axes report standstill at that target, waits a
|
||||
// further settle_wait_ms so the free-running camera has exposed a frame with
|
||||
// the gimbal at rest, then keeps the newest frame. If none is ready it
|
||||
// retries each tick, and gives up on the waypoint after frame_timeout_ms.
|
||||
//
|
||||
// ControlCode 0 = automatic sweep through the ScanGrid (front to back, repeating);
|
||||
// ControlCode 1 = drive yaw to the MQTT-supplied target heading (pitch held).
|
||||
|
|
@ -43,6 +42,12 @@ public:
|
|||
void setImageRate(double rate); // images per second
|
||||
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
|
||||
// scan waypoints target with the calibrated conversion. Called on the control
|
||||
// thread, same as tick(), so no locking is needed.
|
||||
|
|
@ -79,6 +84,9 @@ private:
|
|||
std::string target_heading_ = "0";
|
||||
|
||||
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)
|
||||
long yaw_target_ = 0; // current target, encoder counts
|
||||
long pitch_target_ = 0;
|
||||
|
|
|
|||
|
|
@ -33,23 +33,15 @@ struct CameraConfig {
|
|||
std::vector<std::string> ids; // GigE IP or USB DEV_ id, in order
|
||||
std::vector<std::string> labels = {"RGB", "ACR", "NIR"}; // index -> output subfolder
|
||||
|
||||
// On-camera imaging (Alvium), applied in-session by VimbaCameraSource at start().
|
||||
// Defaults keep frames small enough to transfer reliably on the LattePanda USB3 host
|
||||
// (large ~60 MB frames stall; see docs/known-issues.md "Camera acquisition").
|
||||
int binning = 2; // 1 = full res; 2 = 2x2 (~5 MP, ~15 MB) default
|
||||
int offset_x = 0; // ROI origin; 0/0 + 0 size => sensor maximum (full frame)
|
||||
int offset_y = 0;
|
||||
int width = 0; // ROI width in pixels; 0 => sensor maximum
|
||||
int height = 0; // ROI height in pixels; 0 => sensor maximum
|
||||
std::string pixel_format = "RGB8"; // on-camera de-Bayer + white balance -> colour out
|
||||
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
|
||||
// On-camera imaging settings live in mode files, one per mode: a mode named X
|
||||
// is <mode_dir>/X.ini (see CameraMode.h). Nothing about the sensor is
|
||||
// configured here - a mode file is the whole story, so the two can never
|
||||
// disagree about, say, binning.
|
||||
// Relative => resolved against the DIRECTORY HOLDING config.ini. The deployed
|
||||
// config.ini lives at the repo root (deploy.sh copies the example there), so
|
||||
// the default is written from there, not from config/.
|
||||
std::string mode_dir = "config/camera_modes/rgb";
|
||||
std::string mode = "standard"; // which mode to start in
|
||||
|
||||
// JPEG XL encoding (consumed by ImagePipeline). Near-lossless default.
|
||||
double jxl_distance = 0.8; // 0 = lossless; higher = lossier
|
||||
|
|
@ -58,47 +50,6 @@ struct CameraConfig {
|
|||
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 {
|
||||
// Where captured .jxl images are written. Supports leading ~ and $ENV
|
||||
// expansion. Empty => resolved to a sensible default at load time.
|
||||
|
|
@ -187,7 +138,6 @@ struct AppConfig {
|
|||
NetworkConfig network;
|
||||
SerialConfig serial;
|
||||
CameraConfig camera;
|
||||
CaptureConfig capture; // [Capture] acquisition mode + image quality gate
|
||||
PathsConfig paths;
|
||||
FeaturesConfig features;
|
||||
LoggingConfig logging;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
#pragma once
|
||||
|
||||
#include "fgc/CameraMode.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
|
@ -15,10 +17,6 @@ struct Frame {
|
|||
int channels = 0; // 1 (mono) or 3 (RGB)
|
||||
long long timestamp_ms = 0; // Unix epoch ms
|
||||
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
|
||||
|
|
@ -38,8 +36,6 @@ struct CameraDeviceInfo {
|
|||
// Live sensor telemetry (read from the camera, throttled).
|
||||
double exposure_us = 0.0;
|
||||
double gain_db = 0.0;
|
||||
double wb_red = 0.0;
|
||||
double wb_blue = 0.0;
|
||||
double temperature_c = 0.0;
|
||||
double actual_fps = 0.0;
|
||||
|
||||
|
|
@ -50,8 +46,7 @@ struct CameraDeviceInfo {
|
|||
};
|
||||
|
||||
// Abstraction over the camera array. Implemented by VimbaCameraSource (Allied
|
||||
// Vision Vimba X), MockCameraSource (synthetic frames, no hardware), and
|
||||
// GatedCameraSource (a decorator adding the image-quality gate).
|
||||
// Vision Vimba X) and MockCameraSource (synthetic frames, no hardware).
|
||||
//
|
||||
// Completed frames are delivered to the callback set via setFrameCallback();
|
||||
// the consumer (ImagePipeline) encodes and stores them.
|
||||
|
|
@ -67,32 +62,14 @@ public:
|
|||
virtual void stop() = 0;
|
||||
|
||||
// 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
|
||||
// quality gate it runs the whole acquire/judge/correct loop.
|
||||
// success. The camera free-runs, so this hands over the freshest complete
|
||||
// frame; it returns false when no frame newer than the last one is available
|
||||
// yet, and the caller simply retries.
|
||||
virtual bool trigger() = 0;
|
||||
|
||||
// Optional: change the acquisition frame rate. Default no-op (e.g. mocks).
|
||||
virtual bool setFrameRate(double /*fps*/) { 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; }
|
||||
// Apply a camera mode's feature list (see CameraMode.h). Default: no-op, for
|
||||
// sources with no features to set.
|
||||
virtual bool setMode(const CameraMode& /*mode*/) { return false; }
|
||||
|
||||
virtual void setFrameCallback(FrameCallback cb) = 0;
|
||||
|
||||
|
|
|
|||
|
|
@ -20,10 +20,6 @@ struct CamEvent {
|
|||
int heading_decideg = 0; // yaw heading * 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
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -30,10 +30,6 @@ std::string defaultOutputDir();
|
|||
// ($XDG_DATA_HOME/fire_gimbal_control/logs, else ~/.local/share/...).
|
||||
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.
|
||||
std::string timestampedLogName(const std::string& prefix);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#pragma once
|
||||
|
||||
#include "fgc/CameraMode.h"
|
||||
#include "fgc/Config.h"
|
||||
#include "fgc/ICameraSource.h"
|
||||
|
||||
|
|
@ -8,39 +9,31 @@
|
|||
namespace fgc {
|
||||
|
||||
// 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():
|
||||
// TRIGGERED (default) - TriggerMode=On + TriggerSource=Software. acquireFrame()
|
||||
// fires one TriggerSoftware and returns that frame, so the image belongs to the
|
||||
// waypoint that asked for it and its exposure can be set deliberately.
|
||||
// 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.
|
||||
// The camera free-runs continuously at the mode's AcquisitionFrameRate with its
|
||||
// own auto-exposure tracking the light. trigger() hands over the latest complete
|
||||
// frame, restarting acquisition if the stream has stalled. Encode/save lives in
|
||||
// ImagePipeline.
|
||||
class VimbaCameraSource : public ICameraSource {
|
||||
public:
|
||||
explicit VimbaCameraSource(CameraConfig config);
|
||||
~VimbaCameraSource() override;
|
||||
|
||||
// Must be called before start() to take effect (features are applied there).
|
||||
void setAcquisitionMode(bool triggered, bool manual_exposure);
|
||||
// Mode to apply at start(). Also the mode setMode() replaces.
|
||||
void setInitialMode(CameraMode mode);
|
||||
|
||||
void open() override;
|
||||
void close() override;
|
||||
void start() override;
|
||||
void stop() override;
|
||||
bool trigger() override;
|
||||
bool setFrameRate(double fps) override;
|
||||
bool setMode(const CameraMode& mode) override;
|
||||
void setFrameCallback(FrameCallback cb) override;
|
||||
int cameraCount() const 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:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
|
|
|
|||
|
|
@ -4,18 +4,15 @@
|
|||
#include "fgc/Logger.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
|
||||
namespace fgc {
|
||||
|
||||
// Synthetic camera for development without hardware. On trigger() it generates
|
||||
// a small RGB gradient frame (varying with time so successive captures differ)
|
||||
// and delivers it to the frame callback.
|
||||
//
|
||||
// It also implements the deliberate-acquisition API with a crude sensor model
|
||||
// (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.
|
||||
// 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
|
||||
// end to end with --mock-camera.
|
||||
class MockCameraSource : public ICameraSource {
|
||||
public:
|
||||
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); }
|
||||
int cameraCount() const override { return count_; }
|
||||
|
||||
// ---- Deliberate acquisition, for the quality gate ----
|
||||
|
||||
bool acquireFrame(Frame& out, int /*timeout_ms*/) override {
|
||||
out = synth();
|
||||
++frames_;
|
||||
LOG_TRACE_CAT(LogCat::Camera)
|
||||
<< "RX frame cam0 " << width_ << 'x' << height_ << " exp=" << exposure_us_
|
||||
<< "us gain=" << gain_db_ << "dB (mock)";
|
||||
bool setMode(const CameraMode& mode) override {
|
||||
mode_ = mode.name;
|
||||
LOG_INFO << "[mock] camera mode -> " << mode_;
|
||||
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)
|
||||
// without hardware. Non-hardware fields (temperature, exposure...) stay at 0.
|
||||
std::vector<CameraDeviceInfo> deviceInfo() override {
|
||||
|
|
@ -86,9 +64,8 @@ public:
|
|||
}
|
||||
|
||||
private:
|
||||
// A gradient frame whose overall brightness follows exposure x gain, so the
|
||||
// quality gate sees its corrections take effect. The pattern varies with time
|
||||
// so successive captures differ.
|
||||
// A mid-grey gradient frame. The pattern varies with time so successive
|
||||
// captures differ.
|
||||
Frame synth() const {
|
||||
Frame f;
|
||||
f.width = width_;
|
||||
|
|
@ -100,18 +77,13 @@ private:
|
|||
.count();
|
||||
f.data.resize(static_cast<size_t>(width_) * height_ * 3);
|
||||
|
||||
// Exposure/gain -> brightness. Calibrated so the default 5000 us at 0 dB
|
||||
// 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 double level = 110.0;
|
||||
|
||||
const uint8_t phase = static_cast<uint8_t>(f.timestamp_ms);
|
||||
for (uint32_t y = 0; y < height_; ++y) {
|
||||
for (uint32_t x = 0; x < width_; ++x) {
|
||||
size_t i = (static_cast<size_t>(y) * width_ + x) * 3;
|
||||
// Zero-mean texture around `level` so there is real detail for the
|
||||
// sharpness metric without shifting the mean. The cast matters:
|
||||
// Zero-mean texture around `level`. The cast matters:
|
||||
// (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.
|
||||
const double tex = 20.0 * (static_cast<int>((x + y + phase) % 3) - 1);
|
||||
|
|
@ -131,9 +103,7 @@ private:
|
|||
uint32_t height_;
|
||||
bool started_ = false;
|
||||
long long frames_ = 0;
|
||||
double exposure_us_ = 5000.0;
|
||||
double gain_db_ = 0.0;
|
||||
bool auto_on_ = true;
|
||||
std::string mode_;
|
||||
FrameCallback callback_;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -98,14 +98,10 @@ struct ScanWaypointView {
|
|||
struct CameraConfigView {
|
||||
bool mock = false;
|
||||
std::vector<std::string> ids;
|
||||
std::string pixel_format;
|
||||
int binning = 1;
|
||||
int offset_x = 0, offset_y = 0, width = 0, height = 0; // 0 = full/max
|
||||
int throughput_mbytes = 0;
|
||||
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;
|
||||
// Active camera mode (see CameraMode) and the ones available to switch to.
|
||||
std::string mode, mode_desc;
|
||||
int settle_wait_ms = 0;
|
||||
std::vector<std::string> modes_available;
|
||||
double jxl_distance = 0.0; int jxl_effort = 0;
|
||||
int rotation_deg = 0; // clockwise; 0 = none
|
||||
std::string output_dir;
|
||||
|
|
@ -116,7 +112,7 @@ struct CameraDeviceView {
|
|||
std::string id, model, serial;
|
||||
bool streaming = false;
|
||||
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;
|
||||
};
|
||||
|
||||
|
|
@ -136,19 +132,6 @@ struct CaptureView {
|
|||
bool scan_from_file = false; // grid_file CSV vs generated
|
||||
bool scan_pitch = false; // 2-axis rig (show pitch column)
|
||||
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()).
|
||||
bool has_last = false;
|
||||
|
|
|
|||
8
main.cpp
8
main.cpp
|
|
@ -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
|
||||
|
||||
Application app(std::move(cfg), std::move(opts));
|
||||
try {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,7 +123,6 @@ void ImagePipeline::process(const Frame& frame) {
|
|||
ev.heading_decideg = static_cast<int>(o.yaw_deg * 10);
|
||||
ev.pitch_decideg = static_cast<int>(o.pitch_deg * 10);
|
||||
ev.timestamp_ms = frame.timestamp_ms;
|
||||
ev.degraded = frame.degraded;
|
||||
channel_.publishCamEvent(ev);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(last_event_mutex_);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
#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 <algorithm>
|
||||
|
|
@ -8,10 +19,9 @@
|
|||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include <VmbCPP/VmbCPP.h>
|
||||
|
||||
using namespace VmbCPP;
|
||||
|
||||
namespace fgc {
|
||||
|
|
@ -35,10 +45,10 @@ struct CamStats {
|
|||
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
|
||||
// just give the on-camera auto-exposure/gain/white-balance time to converge to the
|
||||
// current light. This avoids the software-trigger path, which stalls on this host.
|
||||
// just give the on-camera auto-exposure/gain time to converge to the current
|
||||
// light.
|
||||
class FrameObserver : public IFrameObserver {
|
||||
public:
|
||||
FrameObserver(CameraPtr camera, int cam_id, CamStats* stats)
|
||||
|
|
@ -108,43 +118,6 @@ private:
|
|||
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).
|
||||
double getFloat(const CameraPtr& cam, const char* name, double fallback = 0.0) {
|
||||
FeaturePtr f;
|
||||
|
|
@ -160,77 +133,67 @@ std::string getString(const CameraPtr& cam, const char* name) {
|
|||
return v;
|
||||
return {};
|
||||
}
|
||||
// White-balance ratio for a colour channel ("Red"/"Blue"): select then read.
|
||||
double wbRatio(const CameraPtr& cam, const char* channel) {
|
||||
FeaturePtr sel;
|
||||
if (SP_ACCESS(cam)->GetFeatureByName("BalanceRatioSelector", sel) == VmbErrorSuccess)
|
||||
sel->SetValue(channel);
|
||||
return getFloat(cam, "BalanceRatio", 0.0);
|
||||
|
||||
bool parseBool(const std::string& v) {
|
||||
return v == "1" || v == "true" || v == "True" || v == "yes" || v == "on";
|
||||
}
|
||||
|
||||
// Apply the config-driven imaging settings, in-session. Order per the Alvium user
|
||||
// guide: ROI/binning -> throughput -> trigger -> exposure.
|
||||
//
|
||||
// Two acquisition modes:
|
||||
// triggered (default) - TriggerMode=On + TriggerSource=Software: exactly one frame
|
||||
// per TriggerSoftware, so the frame belongs to the waypoint it was asked for and
|
||||
// its exposure can be chosen deliberately. This is also a LOWER USB load than a
|
||||
// continuous stream, which matters on this host (see docs/known-issues.md).
|
||||
// freerun - the older paced stream + keep-latest, kept as a fallback.
|
||||
//
|
||||
// `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);
|
||||
// Write one feature from a mode file, converting the string to whatever type the
|
||||
// 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
|
||||
// failure is a warning, never fatal - the warning IS the feedback that a mode
|
||||
// file has a typo in it.
|
||||
void applyFeature(const CameraPtr& cam, const std::string& name, const std::string& value) {
|
||||
FeaturePtr f;
|
||||
if (SP_ACCESS(cam)->GetFeatureByName(name.c_str(), f) != VmbErrorSuccess) {
|
||||
LOG_WARN << "camera: no such feature '" << name << "' (skipped)";
|
||||
return;
|
||||
}
|
||||
|
||||
// With the quality gate driving exposure, the camera's own continuous auto would
|
||||
// fight it - and its several-frames-to-converge cost is exactly what we are avoiding.
|
||||
const bool auto_exposure = c.exposure_auto && !manual_exposure;
|
||||
const bool auto_gain = c.gain_auto && !manual_exposure;
|
||||
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");
|
||||
VmbFeatureDataType type = VmbFeatureDataUnknown;
|
||||
if (f->GetDataType(type) != VmbErrorSuccess) {
|
||||
LOG_WARN << "camera: cannot read type of feature '" << name << "' (skipped)";
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_INFO << "camera configured: binning=" << c.binning << " fmt=" << c.pixel_format
|
||||
<< " mode=" << (triggered ? "trigger" : "freerun@" + std::to_string(c.stream_fps) + "fps")
|
||||
<< " throughput=" << c.throughput_mbytes << "MB/s"
|
||||
<< " autoExp=" << auto_exposure << " autoWB=" << c.white_balance_auto;
|
||||
VmbErrorType rc = VmbErrorSuccess;
|
||||
try {
|
||||
switch (type) {
|
||||
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
|
||||
|
|
@ -240,12 +203,11 @@ struct VimbaCameraSource::Impl {
|
|||
|
||||
struct Identity { std::string id, model, serial; };
|
||||
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;
|
||||
bool triggered = true; // software trigger vs free-run
|
||||
bool manual_exposure = true; // gate owns exposure/gain
|
||||
CameraMode mode; // applied at start() and by setMode()
|
||||
VmbSystem& sys;
|
||||
std::vector<CameraPtr> cameras;
|
||||
std::vector<IFrameObserverPtr> observers;
|
||||
|
|
@ -283,31 +245,6 @@ struct VimbaCameraSource::Impl {
|
|||
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)
|
||||
|
|
@ -347,25 +284,41 @@ void VimbaCameraSource::close() {
|
|||
impl_->sys.Shutdown();
|
||||
}
|
||||
|
||||
void VimbaCameraSource::setAcquisitionMode(bool triggered, bool manual_exposure) {
|
||||
impl_->triggered = triggered;
|
||||
impl_->manual_exposure = manual_exposure;
|
||||
}
|
||||
void VimbaCameraSource::setInitialMode(CameraMode mode) { impl_->mode = std::move(mode); }
|
||||
|
||||
void VimbaCameraSource::start() {
|
||||
for (auto& cam : impl_->cameras)
|
||||
configureCamera(cam, impl_->config, impl_->triggered, impl_->manual_exposure);
|
||||
for (auto& cam : impl_->cameras) applyMode(cam, impl_->mode);
|
||||
impl_->startAcquisition();
|
||||
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() {
|
||||
impl_->stopAcquisition();
|
||||
impl_->started = false;
|
||||
}
|
||||
|
||||
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
|
||||
// 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
|
||||
|
|
@ -406,72 +359,6 @@ bool VimbaCameraSource::trigger() {
|
|||
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); }
|
||||
|
||||
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.temperature_c = getFloat(cam, "DeviceTemperature");
|
||||
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.exposure_us = t.exposure_us;
|
||||
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.actual_fps = t.actual_fps;
|
||||
d.frames_delivered = s.delivered.load();
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@
|
|||
#include "fgc/DumpParser.h"
|
||||
#include "fgc/HelpText.h"
|
||||
#include "fgc/Paths.h"
|
||||
#include "fgc/ExposureStore.h"
|
||||
#include "fgc/GatedCameraSource.h"
|
||||
#include "fgc/CameraMode.h"
|
||||
#include "fgc/ICameraSource.h"
|
||||
#include "fgc/IControlChannel.h"
|
||||
#include "fgc/IEnvSensor.h"
|
||||
|
|
@ -183,12 +182,10 @@ struct Application::Impl {
|
|||
std::unique_ptr<IControlChannel> channel;
|
||||
std::unique_ptr<IMotorController> motor;
|
||||
std::unique_ptr<ICameraSource> camera;
|
||||
// Borrowed view of the quality gate inside `camera` (null when it is off), so
|
||||
// buildSnapshot can report the last capture's verdict without a downcast.
|
||||
GatedCameraSource* gate = nullptr;
|
||||
// Per-angle exposure memory. Owned here (not by the gate) so it outlives the
|
||||
// camera and can be flushed to disk on shutdown.
|
||||
std::unique_ptr<ExposureStore> exposure_store;
|
||||
// Camera modes, loaded once at startup and kept so a live switch needs no disk
|
||||
// access. `camera_mode` is the active one.
|
||||
std::map<std::string, CameraMode> camera_modes;
|
||||
std::string camera_mode;
|
||||
std::unique_ptr<IImuSource> imu;
|
||||
std::unique_ptr<IEnvSensor> env;
|
||||
std::unique_ptr<ImagePipeline> pipeline;
|
||||
|
|
@ -290,8 +287,7 @@ struct Application::Impl {
|
|||
std::chrono::milliseconds(cfg.env.period_ms));
|
||||
}
|
||||
|
||||
// The bare camera driver, before any quality gating is layered on.
|
||||
std::unique_ptr<ICameraSource> makeRawCamera() {
|
||||
std::unique_ptr<ICameraSource> makeCamera() {
|
||||
bool mock = opts.mock_camera.value_or(cfg.features.mock_camera);
|
||||
#if !FGC_WITH_VIMBA
|
||||
if (!mock) {
|
||||
|
|
@ -305,67 +301,44 @@ struct Application::Impl {
|
|||
}
|
||||
#if FGC_WITH_VIMBA
|
||||
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;
|
||||
#else
|
||||
return std::make_unique<MockCameraSource>(1);
|
||||
#endif
|
||||
}
|
||||
|
||||
// Wrap the driver in the quality gate, which turns one trigger() into
|
||||
// shoot -> judge -> correct -> reshoot, seeded from the per-angle store.
|
||||
std::unique_ptr<ICameraSource> makeCamera() {
|
||||
auto raw = makeRawCamera();
|
||||
if (!cfg.capture.quality_gate) {
|
||||
LOG_INFO << "capture: quality gate disabled (mode=" << cfg.capture.mode << ')';
|
||||
return raw;
|
||||
// Load every mode file up front, so a typo in a mode nobody is using today
|
||||
// still fails at startup rather than the first time someone switches to it.
|
||||
void loadModes() {
|
||||
camera_modes = fgc::loadCameraModes(cfg.camera.mode_dir);
|
||||
if (camera_modes.find(cfg.camera.mode) == camera_modes.end()) {
|
||||
std::string known;
|
||||
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>(
|
||||
cfg.capture.exposure_store, cfg.capture.angle_quantum_deg, cfg.capture.store_stale_s);
|
||||
if (exposure_store->load())
|
||||
LOG_INFO << "exposure store: " << exposure_store->size() << " angle(s) from "
|
||||
<< exposure_store->path();
|
||||
else
|
||||
LOG_INFO << "exposure store: starting empty (" << exposure_store->path() << ')';
|
||||
|
||||
GatedCameraSource::Params p;
|
||||
p.enabled = true;
|
||||
p.max_attempts = cfg.capture.max_attempts;
|
||||
p.min_attempts = cfg.capture.min_attempts;
|
||||
p.acquire_timeout_ms = cfg.capture.acquire_timeout_ms;
|
||||
p.settle_delay_ms = cfg.capture.settle_delay_ms;
|
||||
p.defaults = {cfg.capture.default_exposure_us, cfg.capture.default_gain_db};
|
||||
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;
|
||||
// Switch the live camera mode by name. Returns false (and warns) if unknown.
|
||||
bool applyMode(const std::string& name) {
|
||||
auto it = camera_modes.find(name);
|
||||
if (it == camera_modes.end()) {
|
||||
std::string known;
|
||||
for (const auto& [n, m] : camera_modes) known += (known.empty() ? "" : ", ") + n;
|
||||
LOG_WARN << "unknown camera mode '" << name << "' (available: " << known << ')';
|
||||
return false;
|
||||
}
|
||||
if (camera) camera->setMode(it->second);
|
||||
camera_mode = name;
|
||||
if (scheduler)
|
||||
scheduler->setSettleWait(it->second.settle_wait_ms, it->second.frame_timeout_ms);
|
||||
LOG_INFO << "camera mode -> " << name << " (" << it->second.description << ')';
|
||||
return true;
|
||||
}
|
||||
|
||||
std::unique_ptr<IUserInterface> makeUi() {
|
||||
|
|
@ -548,19 +521,13 @@ struct Application::Impl {
|
|||
auto& cc = s.capture.config;
|
||||
cc.mock = opts.mock_camera.value_or(cfg.features.mock_camera);
|
||||
cc.ids = cfg.camera.ids;
|
||||
cc.pixel_format = cfg.camera.pixel_format;
|
||||
cc.binning = cfg.camera.binning;
|
||||
cc.offset_x = cfg.camera.offset_x;
|
||||
cc.offset_y = cfg.camera.offset_y;
|
||||
cc.width = cfg.camera.width;
|
||||
cc.height = cfg.camera.height;
|
||||
cc.throughput_mbytes = cfg.camera.throughput_mbytes;
|
||||
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.mode = camera_mode;
|
||||
if (auto it = camera_modes.find(camera_mode); it != camera_modes.end()) {
|
||||
cc.mode_desc = it->second.description;
|
||||
cc.settle_wait_ms = it->second.settle_wait_ms;
|
||||
}
|
||||
cc.modes_available.clear();
|
||||
for (const auto& [name, m] : camera_modes) cc.modes_available.push_back(name);
|
||||
cc.jxl_distance = cfg.camera.jxl_distance;
|
||||
cc.jxl_effort = cfg.camera.jxl_effort;
|
||||
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.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.wb_red = d.wb_red; v.wb_blue = d.wb_blue;
|
||||
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.restarts = d.restarts;
|
||||
|
|
@ -583,20 +549,6 @@ struct Application::Impl {
|
|||
}
|
||||
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
|
||||
// scheduler that mutates it, so no locking is needed).
|
||||
s.capture.scan_from_file = !cfg.scan.grid_file.empty();
|
||||
|
|
@ -1426,7 +1378,10 @@ struct Application::Impl {
|
|||
handleTrace(c);
|
||||
} else if (c.verb == "set") {
|
||||
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 == "jxle" && c.has_value) pipeline->setEffort(static_cast<int>(c.value));
|
||||
else if (c.option == "display") pipeline->setDisplay(c.value != 0.0);
|
||||
|
|
@ -1479,6 +1434,8 @@ struct Application::Impl {
|
|||
if (Logger::categories())
|
||||
LOG_INFO << "wire trace enabled: " << traceNames(Logger::categories());
|
||||
|
||||
loadModes(); // before makeCamera(): the mode is applied at start()
|
||||
|
||||
channel = makeChannel();
|
||||
motor = makeMotor();
|
||||
camera = makeCamera();
|
||||
|
|
@ -1533,6 +1490,10 @@ struct Application::Impl {
|
|||
LOG_INFO << "scan grid: " << grid.size() << " waypoints";
|
||||
scheduler = std::make_unique<CaptureScheduler>(*motor, *camera, *channel, cfg.image_rate(),
|
||||
cfg.geometry, grid);
|
||||
{
|
||||
const CameraMode& m = camera_modes.at(camera_mode);
|
||||
scheduler->setSettleWait(m.settle_wait_ms, m.frame_timeout_ms);
|
||||
}
|
||||
|
||||
motor->start();
|
||||
if (imu) imu->start();
|
||||
|
|
@ -1592,10 +1553,6 @@ struct Application::Impl {
|
|||
pipeline->stop();
|
||||
camera->stop();
|
||||
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 (env) env->stop();
|
||||
motor->stop();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -37,10 +37,16 @@ CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camer
|
|||
void CaptureScheduler::setCaptureActive(bool active) {
|
||||
capture_active_ = active;
|
||||
moving_ = false; // restart the move/settle cycle cleanly
|
||||
settled_at_ms_ = 0;
|
||||
resetTimer();
|
||||
}
|
||||
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_; }
|
||||
void CaptureScheduler::resetTimer() { timer_start_ = now_ms_(); }
|
||||
|
||||
|
|
@ -110,24 +116,48 @@ void CaptureScheduler::tick() {
|
|||
}
|
||||
sendMove();
|
||||
moving_ = true;
|
||||
settled_at_ms_ = 0;
|
||||
resetTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Moving: trigger once both axes have settled at the target.
|
||||
if (settledAt(t)) {
|
||||
// Moving: wait for standstill at the target, then hold still a moment before
|
||||
// keeping a frame.
|
||||
if (settled_at_ms_ == 0) {
|
||||
if (!settledAt(t)) return;
|
||||
settled_at_ms_ = now_ms_();
|
||||
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 (control_code_ == 0)
|
||||
LOG_INFO << "captured waypoint " << (grid_.index() + 1) << "/" << grid_.size();
|
||||
else
|
||||
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;
|
||||
settled_at_ms_ = 0;
|
||||
if (control_code_ == 0) grid_.advance();
|
||||
resetTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace fgc
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
cfg.camera.binning = getInt(kv, "Camera.binning", cfg.camera.binning);
|
||||
cfg.camera.offset_x = getInt(kv, "Camera.offset_x", cfg.camera.offset_x);
|
||||
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.mode_dir = get(kv, "Camera.mode_dir", cfg.camera.mode_dir);
|
||||
cfg.camera.mode = get(kv, "Camera.mode", cfg.camera.mode);
|
||||
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.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)
|
||||
throw std::runtime_error("Camera.jxl_distance must be >= 0 (0 = lossless)");
|
||||
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)
|
||||
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");
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -82,7 +82,10 @@ const std::vector<HelpSection>& helpCatalog() {
|
|||
{"start", "Begin the capture scan.", {}},
|
||||
{"stop", "Halt the capture scan.", {}},
|
||||
{"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 jxle <effort>", "Set JPEG-XL encode effort.", {}},
|
||||
{"set camera display <0|1>", "Toggle the local display window.", {}},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -102,12 +102,6 @@ std::string defaultLogDir() {
|
|||
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::time_t t = std::time(nullptr);
|
||||
std::tm tm{};
|
||||
|
|
|
|||
|
|
@ -120,8 +120,7 @@ void MqttControlChannel::publishCamEvent(const CamEvent& e) {
|
|||
std::string payload = "{ \"fwt\":\"" + e.tower + "\" ,\"cam\":\"" + e.camera +
|
||||
"\", \"hdg\":" + std::to_string(e.heading_decideg) +
|
||||
", \"pit\":" + std::to_string(e.pitch_decideg) +
|
||||
", \"time\":" + std::to_string(e.timestamp_ms) +
|
||||
", \"degraded\":" + (e.degraded ? "true" : "false") + " }";
|
||||
", \"time\":" + std::to_string(e.timestamp_ms) + " }";
|
||||
publish(topic_cam_event_, payload);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -208,10 +208,8 @@ Element cameraPanel(const CaptureView& c) {
|
|||
rows.push_back(hbox({text("scan ") | dim,
|
||||
text(" GRID LOAD FAILED ") | color(Color::Red) | bold,
|
||||
text(" c for details") | dim}));
|
||||
if (c.gate_active && c.gate_degraded)
|
||||
rows.push_back(hbox({text("quality ") | dim,
|
||||
text(" DEGRADED ") | color(Color::Red) | bold,
|
||||
text(" c for details") | dim}));
|
||||
rows.push_back(hbox({text("mode ") | dim,
|
||||
text(c.config.mode.empty() ? "—" : c.config.mode) | color(Color::Cyan)}));
|
||||
if (c.has_last) {
|
||||
long long now = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
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("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 ---
|
||||
rows.push_back(separator());
|
||||
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")));
|
||||
rows.push_back(row(" exposure", text(num(d.exposure_us, 0) + " us gain " +
|
||||
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 " +
|
||||
num(d.actual_fps, 2) + " fps")));
|
||||
rows.push_back(row(" frames", hbox({
|
||||
|
|
@ -303,25 +277,17 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
|
|||
// --- Imaging config ---
|
||||
const CameraConfigView& cf = c.config;
|
||||
rows.push_back(separator());
|
||||
rows.push_back(text("IMAGING CONFIG") | bold | color(Color::Blue));
|
||||
rows.push_back(row("mode", 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("ROI", text((cf.width > 0 || cf.height > 0)
|
||||
? (std::to_string(cf.width) + "x" + std::to_string(cf.height) +
|
||||
" @ " + std::to_string(cf.offset_x) + "," +
|
||||
std::to_string(cf.offset_y))
|
||||
: "full sensor")));
|
||||
rows.push_back(row("throughput", text(std::to_string(cf.throughput_mbytes) + " MB/s " +
|
||||
"(stream " + num(cf.stream_fps, 1) + " fps)")));
|
||||
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(text("CAMERA MODE") | bold | color(Color::Blue));
|
||||
rows.push_back(row("driver", text(cf.mock ? "MOCK (simulated)" : "real camera (Vimba X)")));
|
||||
rows.push_back(row("mode", hbox({text(cf.mode.empty() ? "—" : cf.mode) | color(Color::Cyan) | bold,
|
||||
text(" " + cf.mode_desc) | dim})));
|
||||
rows.push_back(row("settle", text(std::to_string(cf.settle_wait_ms) +
|
||||
" ms after standstill before keeping a frame")));
|
||||
{
|
||||
std::string others;
|
||||
for (const auto& m : cf.modes_available) others += (others.empty() ? "" : ", ") + m;
|
||||
rows.push_back(row("available", text(others.empty() ? "—" : others) | dim));
|
||||
}
|
||||
rows.push_back(row("encoding", text("JPEG XL distance " + num(cf.jxl_distance, 2) +
|
||||
" effort " + std::to_string(cf.jxl_effort))));
|
||||
rows.push_back(row("rotation", text(cf.rotation_deg == 0
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ add_executable(fgc_tests
|
|||
test_paths.cpp
|
||||
test_singleinstance.cpp
|
||||
test_config.cpp
|
||||
test_cameramode.cpp
|
||||
test_telemetry.cpp
|
||||
test_command.cpp
|
||||
test_scheduler.cpp
|
||||
|
|
@ -32,10 +33,6 @@ add_executable(fgc_tests
|
|||
test_scroll.cpp
|
||||
test_mtiprotocol.cpp
|
||||
test_sht41.cpp
|
||||
test_imagequality.cpp
|
||||
test_exposurepolicy.cpp
|
||||
test_exposurestore.cpp
|
||||
test_gatedcamera.cpp
|
||||
test_dumpparser.cpp
|
||||
test_helptext.cpp
|
||||
test_diagparser.cpp
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -60,53 +60,6 @@ TEST_CASE("ConfigLoader maps [Env]/env feature flags") {
|
|||
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") {
|
||||
// Plain std::stoi() stops at the 'x' and returns 0, which would silently
|
||||
// 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"}}));
|
||||
}
|
||||
|
||||
TEST_CASE("Camera imaging + encoding config: defaults and overrides") {
|
||||
// Defaults chosen for reliable transfer on the LattePanda USB3 host + near-lossless output.
|
||||
TEST_CASE("Camera mode selection + encoding config: defaults and overrides") {
|
||||
AppConfig d = ConfigLoader::fromMap({});
|
||||
CHECK(d.camera.binning == 2);
|
||||
CHECK(d.camera.pixel_format == "RGB8");
|
||||
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.mode == "standard");
|
||||
CHECK(d.camera.mode_dir.find("config/camera_modes/rgb") != std::string::npos);
|
||||
CHECK(d.camera.jxl_distance == doctest::Approx(0.8));
|
||||
CHECK(d.camera.jxl_effort == 4);
|
||||
CHECK(d.camera.rotation_deg == 0);
|
||||
|
||||
AppConfig c = ConfigLoader::fromMap({
|
||||
{"Camera.binning", "1"},
|
||||
{"Camera.pixel_format", "BayerRG8"},
|
||||
{"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.mode_dir", "/tmp/modes"},
|
||||
{"Camera.mode", "night"},
|
||||
{"Camera.jxl_distance", "0"},
|
||||
{"Camera.jxl_effort", "6"},
|
||||
{"Camera.rotation_deg", "180"},
|
||||
});
|
||||
CHECK(c.camera.binning == 1);
|
||||
CHECK(c.camera.pixel_format == "BayerRG8");
|
||||
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.mode_dir == "/tmp/modes");
|
||||
CHECK(c.camera.mode == "night");
|
||||
CHECK(c.camera.jxl_distance == doctest::Approx(0.0)); // lossless
|
||||
CHECK(c.camera.jxl_effort == 6);
|
||||
CHECK(c.camera.rotation_deg == 180);
|
||||
}
|
||||
|
||||
TEST_CASE("Camera config validates ranges") {
|
||||
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.binning", "0"}}));
|
||||
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.throughput_mbytes", "0"}}));
|
||||
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.stream_fps", "0"}}));
|
||||
// mode_dir / mode are not validated here: a blank value falls back to the
|
||||
// default, and whether the mode file actually exists is checked at load time
|
||||
// (loadCameraModes), which can say what IS available.
|
||||
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", "10"}}));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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,
|
||||
[&] { return clock; });
|
||||
sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test
|
||||
sch.setCaptureActive(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.
|
||||
settleAt(motor, 0, 0);
|
||||
clock = 1700;
|
||||
sch.tick();
|
||||
sch.tick(); // first tick records standstill
|
||||
clock = 1710;
|
||||
sch.tick(); // next one keeps the frame
|
||||
CHECK(cam.triggers == 1);
|
||||
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}});
|
||||
|
||||
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);
|
||||
chan.next.control_code_available = true;
|
||||
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;
|
||||
clock = 1700;
|
||||
sch.tick();
|
||||
clock = 1710;
|
||||
sch.tick();
|
||||
CHECK(cam.triggers == 1);
|
||||
|
||||
clock = 2800;
|
||||
|
|
@ -149,6 +155,7 @@ TEST_CASE("CaptureScheduler ControlCode 1 drives yaw to the target heading") {
|
|||
FakeChannel chan;
|
||||
ScanGrid grid({{0.0, 0.0}});
|
||||
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);
|
||||
|
||||
chan.next.control_code_available = true;
|
||||
|
|
@ -170,6 +177,7 @@ TEST_CASE("CaptureScheduler clamps the target heading to the soft range") {
|
|||
FakeChannel chan;
|
||||
ScanGrid grid({{0.0, 0.0}});
|
||||
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);
|
||||
|
||||
chan.next.control_code_available = true;
|
||||
|
|
@ -191,6 +199,7 @@ TEST_CASE("CaptureScheduler::setGeometry re-targets with the recalibrated conver
|
|||
FakeChannel chan;
|
||||
ScanGrid grid({{0.0, 0.0}});
|
||||
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);
|
||||
|
||||
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.
|
||||
settleAt(motor, 450, 0);
|
||||
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
|
||||
// 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(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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue