From f1342965de8b42b0fb908f55c8045d5210e0b69e Mon Sep 17 00:00:00 2001 From: pgdalmeida Date: Wed, 26 Aug 2026 15:52:47 +0200 Subject: [PATCH] Camera is now configured by per-mode files; quality gate removed --- CMakeLists.txt | 5 +- README.md | 20 +- config/camera_modes/rgb/night.ini | 110 ++++++++++ config/camera_modes/rgb/standard.ini | 106 +++++++++ config/config.example.ini | 100 ++------- deploy.sh | 64 +++++- docs/architecture.md | 143 ++++++------- docs/configuration.md | 105 ++++----- docs/deployment.md | 4 +- docs/known-issues.md | 70 +++--- docs/modules-reference.md | 15 +- docs/mqtt-api.md | 3 +- docs/roadmap.md | 17 +- docs/test-roadmap.md | 19 +- include/fgc/CameraMode.h | 42 ++++ include/fgc/CaptureScheduler.h | 22 +- include/fgc/Config.h | 68 +----- include/fgc/ExposurePolicy.h | 51 ----- include/fgc/ExposureStore.h | 78 ------- include/fgc/GatedCameraSource.h | 91 -------- include/fgc/ICameraSource.h | 41 +--- include/fgc/IControlChannel.h | 4 - include/fgc/ImageQuality.h | 44 ---- include/fgc/Paths.h | 4 - include/fgc/VimbaCameraSource.h | 27 +-- include/fgc/mock/MockCameraSource.h | 52 +---- include/fgc/ui/UiSnapshot.h | 27 +-- main.cpp | 10 +- src/camera/ImagePipeline.cpp | 1 - src/camera/VimbaCameraSource.cpp | 309 +++++++++------------------ src/core/Application.cpp | 149 +++++-------- src/core/CameraMode.cpp | 95 ++++++++ src/core/CaptureScheduler.cpp | 54 +++-- src/core/Config.cpp | 74 +------ src/core/ExposurePolicy.cpp | 125 ----------- src/core/ExposureStore.cpp | 147 ------------- src/core/GatedCameraSource.cpp | 170 --------------- src/core/HelpText.cpp | 5 +- src/core/ImageQuality.cpp | 91 -------- src/core/Paths.cpp | 6 - src/mqtt/MqttControlChannel.cpp | 3 +- src/ui/TuiUi.cpp | 60 ++---- tests/CMakeLists.txt | 5 +- tests/test_cameramode.cpp | 168 +++++++++++++++ tests/test_config.cpp | 91 +------- tests/test_exposurepolicy.cpp | 171 --------------- tests/test_exposurestore.cpp | 124 ----------- tests/test_gatedcamera.cpp | 254 ---------------------- tests/test_imagequality.cpp | 114 ---------- tests/test_scheduler.cpp | 81 ++++++- 50 files changed, 1141 insertions(+), 2498 deletions(-) create mode 100644 config/camera_modes/rgb/night.ini create mode 100644 config/camera_modes/rgb/standard.ini create mode 100644 include/fgc/CameraMode.h delete mode 100644 include/fgc/ExposurePolicy.h delete mode 100644 include/fgc/ExposureStore.h delete mode 100644 include/fgc/GatedCameraSource.h delete mode 100644 include/fgc/ImageQuality.h create mode 100644 src/core/CameraMode.cpp delete mode 100644 src/core/ExposurePolicy.cpp delete mode 100644 src/core/ExposureStore.cpp delete mode 100644 src/core/GatedCameraSource.cpp delete mode 100644 src/core/ImageQuality.cpp create mode 100644 tests/test_cameramode.cpp delete mode 100644 tests/test_exposurepolicy.cpp delete mode 100644 tests/test_exposurestore.cpp delete mode 100644 tests/test_gatedcamera.cpp delete mode 100644 tests/test_imagequality.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index be7819f..88de5df 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/README.md b/README.md index 902f034..68002e2 100644 --- a/README.md +++ b/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//fire_gimbal_control.lock` (or `/tmp/fire_gimbal_control-.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 ` | Capture rate, in images per second. | | `set camera fps ` | Camera sensor frame rate. | +| `set camera mode ` | Switch the camera mode (`standard`, `night`, or any other file in `[Camera] mode_dir`). | | `set camera jxlq ` | JPEG XL quality as butteraugli **distance** (lower = higher quality / larger files). | | `set camera jxle ` | 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 `. 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 diff --git a/config/camera_modes/rgb/night.ini b/config/camera_modes/rgb/night.ini new file mode 100644 index 0000000..d2524a9 --- /dev/null +++ b/config/camera_modes/rgb/night.ini @@ -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 diff --git a/config/camera_modes/rgb/standard.ini b/config/camera_modes/rgb/standard.ini new file mode 100644 index 0000000..beee36e --- /dev/null +++ b/config/camera_modes/rgb/standard.ini @@ -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 diff --git a/config/config.example.ini b/config/config.example.ini index cb21a61..bf82a88 100644 --- a/config/config.example.ini +++ b/config/config.example.ini @@ -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 /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 `. +; 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 diff --git a/deploy.sh b/deploy.sh index f5e684c..65c1a31 100755 --- a/deploy.sh +++ b/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- 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-'; \ + 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' && \ - 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" +# 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" diff --git a/docs/architecture.md b/docs/architecture.md index a0f7629..4689a59 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 ,` 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 `/