From a5ad8142609f5384448b7f0fe792ae29bcf52335 Mon Sep 17 00:00:00 2001 From: pgdalmeida Date: Fri, 4 Sep 2026 14:12:05 +0200 Subject: [PATCH] Camera settings now come from VimbaX profile XMLs; explicit per-camera config --- CMakeLists.txt | 4 +- README.md | 9 +- config/camera_modes/rgb/night.ini | 110 ------------- config/camera_modes/rgb/standard.ini | 106 ------------ config/camera_profiles/rgb/README.md | 66 ++++++++ config/config.example.ini | 55 ++++--- deploy.sh | 18 +- docs/architecture.md | 39 ++--- docs/configuration.md | 87 +++++----- docs/known-issues.md | 10 +- docs/modules-reference.md | 16 +- docs/roadmap.md | 2 +- include/fgc/CameraMode.h | 42 ----- include/fgc/CameraProfile.h | 50 ++++++ include/fgc/CameraSettingsXml.h | 55 +++++++ include/fgc/CaptureScheduler.h | 2 +- include/fgc/Config.h | 57 +++++-- include/fgc/Hash.h | 25 +++ include/fgc/ICameraSource.h | 12 +- include/fgc/IMotorController.h | 8 +- include/fgc/ImagePipeline.h | 35 ++-- include/fgc/TelemetryParser.h | 3 +- include/fgc/VimbaCameraSource.h | 22 +-- include/fgc/mock/MockCameraSource.h | 14 +- include/fgc/mock/MockMotorController.h | 10 ++ include/fgc/ui/UiSnapshot.h | 33 ++-- src/camera/ImagePipeline.cpp | 22 ++- src/camera/VimbaCameraSource.cpp | 142 ++++++++-------- src/core/Application.cpp | 210 +++++++++++++++++------- src/core/CameraMode.cpp | 95 ----------- src/core/CameraProfile.cpp | 80 +++++++++ src/core/CameraSettingsXml.cpp | 219 +++++++++++++++++++++++++ src/core/Config.cpp | 125 +++++++++++--- src/core/Hash.cpp | 40 +++++ src/core/HelpText.cpp | 11 +- src/core/TelemetryParser.cpp | 1 + src/ui/TuiUi.cpp | 74 ++++++--- src/ui/UiSnapshot.cpp | 4 +- tests/CMakeLists.txt | 4 +- tests/test_cameramode.cpp | 168 ------------------- tests/test_cameraprofile.cpp | 147 +++++++++++++++++ tests/test_camerasettingsxml.cpp | 207 +++++++++++++++++++++++ tests/test_config.cpp | 125 ++++++++++---- tests/test_hash.cpp | 47 ++++++ tests/test_telemetry.cpp | 33 ++++ tests/test_uisnapshot.cpp | 8 +- 46 files changed, 1727 insertions(+), 925 deletions(-) delete mode 100644 config/camera_modes/rgb/night.ini delete mode 100644 config/camera_modes/rgb/standard.ini create mode 100644 config/camera_profiles/rgb/README.md delete mode 100644 include/fgc/CameraMode.h create mode 100644 include/fgc/CameraProfile.h create mode 100644 include/fgc/CameraSettingsXml.h create mode 100644 include/fgc/Hash.h delete mode 100644 src/core/CameraMode.cpp create mode 100644 src/core/CameraProfile.cpp create mode 100644 src/core/CameraSettingsXml.cpp create mode 100644 src/core/Hash.cpp delete mode 100644 tests/test_cameramode.cpp create mode 100644 tests/test_cameraprofile.cpp create mode 100644 tests/test_camerasettingsxml.cpp create mode 100644 tests/test_hash.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 67c954b..9d2e62a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -60,7 +60,9 @@ add_library(fgc_core STATIC src/core/Calibration.cpp src/core/CalibrationRoutine.cpp src/core/MtiProtocol.cpp - src/core/CameraMode.cpp + src/core/CameraProfile.cpp + src/core/CameraSettingsXml.cpp + src/core/Hash.cpp src/core/ChannelHistory.cpp src/core/ChannelTopics.cpp src/sensors/Sht41Protocol.cpp diff --git a/README.md b/README.md index 68002e2..54dd5ed 100644 --- a/README.md +++ b/README.md @@ -135,7 +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 profile [] ` | Switch a camera's profile (any `.xml` in its `profile_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. | @@ -150,9 +150,10 @@ discarded, and their job is simply to keep that exposure controller converged. A 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 +Which rate and which exposure limits apply comes from a **camera profile**: a settings XML exported from +the VimbaX Viewer and handed straight to the Vimba X SDK, so the camera gets exactly the combination the +GUI validated. `standard` runs 10 Hz for daylight, `night` runs 1 Hz with long exposures; switch live with +`set camera profile `. Details in [docs/architecture.md](docs/architecture.md#image-capture); the mode-file format is `[Camera]` in [docs/configuration.md](docs/configuration.md). diff --git a/config/camera_modes/rgb/night.ini b/config/camera_modes/rgb/night.ini deleted file mode 100644 index d2524a9..0000000 --- a/config/camera_modes/rgb/night.ini +++ /dev/null @@ -1,110 +0,0 @@ -; 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 deleted file mode 100644 index beee36e..0000000 --- a/config/camera_modes/rgb/standard.ini +++ /dev/null @@ -1,106 +0,0 @@ -; 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/camera_profiles/rgb/README.md b/config/camera_profiles/rgb/README.md new file mode 100644 index 0000000..a5efaac --- /dev/null +++ b/config/camera_profiles/rgb/README.md @@ -0,0 +1,66 @@ +# RGB camera profiles + +A profile is a **settings XML exported from the VimbaX Viewer**. A profile named +`X` is `X.xml` in this directory, and `[Camera.rgb] profile = X` in `config.ini` +selects it. + +The program hands the XML straight to the Vimba X SDK (`LoadSettings`). Nothing +else writes camera settings, and there is no fallback — a missing `X.xml` is a +startup error. That is deliberate: GenICam validity is *stateful* +(`AcquisitionFrameRate` is only writable once `AcquisitionFrameRateEnable` is +set, `Width`'s maximum depends on binning, `IntensityAutoPrecedence` takes +`Minimizenoise` but not `MinimizeNoise`), so a hand-written list can be +individually plausible and collectively wrong. The Viewer greys out invalid +combinations as you configure; that is the guardrail this design relies on. + +## These files live on the device only + +The XMLs are exported on the LattePanda, because that is where the camera is, so +they exist nowhere in the repo. `deploy.sh` therefore excludes `*.xml` and +`*.txt` under this directory from its `rsync --delete` — without that, the next +deploy erases them. This README does sync; it is repo documentation. + +Keep a copy somewhere durable if a profile represents real tuning effort: a +reflash or a rebuilt device starts with an empty directory. + +## To create or change a profile + +1. Open the VimbaX Viewer and connect to the camera. +2. Configure it. The Viewer will not let you pick an invalid combination. +3. Save the settings XML over `.xml` here. +4. Restart. The program notices the file changed, regenerates `.txt`, + and logs every setting that moved. + +## The `.txt` files + +Beside each `X.xml` the program writes `X.txt`: a generated, human-readable +record of the same settings, so a profile can be read and diffed without opening +the Viewer. **It configures nothing.** Editing it changes nothing; it is +overwritten whenever the XML changes. + +## Profiles this rig expects + +`config.ini` ships referencing `standard` and `night`. They were previously +hand-written; these are the values they used, as a starting point when +recreating them in the Viewer. Everything not listed was identical. + +| Setting | `standard` | `night` | +|---|---|---| +| `AcquisitionFrameRate` | 10 | 1 | +| `SensorBitDepth` | Bpp8 | Bpp12 | +| `ExposureAutoMin` | 200 | 1000 | +| `ExposureAutoMax` | 20000 | 500000 | +| `GainAutoMax` | 24 | 48 | +| `IntensityAutoPrecedence` | Minimizeexposuretime | Minimizenoise | +| `DeviceIndicatorMode` | Active | Off | +| `DeviceIndicatorLuminance` | 10 | 0 | + +Both used 2×2 binning at `RGB8` (2256×2256, 15.3 MB/frame) and a 250 MB/s +`DeviceLinkThroughputLimit`. **That binning is not optional at 10 Hz**: a +full-resolution RGB8 frame is 61 MB, which caps out near 4 fps under that limit. + +The full previous definitions are in git history at +`config/camera_modes/rgb/{standard,night}.ini`. + +`settle_wait_ms` / `frame_timeout_ms` are *not* camera settings and are not in +the XML — they live in `config.ini` under `[Camera.rgb.]`. diff --git a/config/config.example.ini b/config/config.example.ini index a9716d9..d3608eb 100644 --- a/config/config.example.ini +++ b/config/config.example.ini @@ -27,32 +27,43 @@ mqtt_user = CHANGE_ME mqtt_pw = CHANGE_ME [Camera] -; Camera IDs: GigE IP (e.g. 192.168.11.101) or USB device ID (e.g. DEV_1AB22C0AADED). -; Leave entries blank/absent for cameras you do not have. Order maps to RGB, ACR, NIR. -id_Cam1 = DEV_0000000000 -id_Cam2 = -id_Cam3 = -id_Cam4 = -; --- 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. +; Defaults inherited by every camera block below; each may override them. +; JPEG XL encoding. distance: 0 = lossless, ~0.8 = near-lossless; effort 1..9. jxl_distance = 0.8 jxl_effort = 4 -; Clockwise rotation applied to saved images: 0, 90, 180 or 270. Depends on how the -; camera is mounted. 0 = store frames exactly as the sensor delivers them. -; (Builds before this key existed always rotated 90 CCW, i.e. 270.) + +; --- One [Camera.] block per camera ----------------------------------- +; Nothing is implied by position: the block names the camera, `label` names its +; output folder and its CamEvent "cam" field, and `driver` picks the backend. +; +; The camera's own settings are NOT here. They live in a settings XML exported +; from the VimbaX Viewer - the Viewer validates combinations as you configure, +; which a text file cannot - and the program hands that XML to the SDK. A +; profile named X is /X.xml. Beside it the program writes X.txt, a +; generated human-readable record of the same settings; it configures nothing. +[Camera.rgb] +enabled = true +driver = vimba ; only vimba is implemented +id = DEV_0000000000 ; USB DEV_ id, or a GigE IP +label = RGB ; output subdir + CamEvent "cam" +profile_dir = config/camera_profiles/rgb +profile = standard ; /standard.xml +; Clockwise rotation applied to saved images: 0, 90, 180 or 270. rotation_deg = 0 +; Per-profile timing. Not camera settings - how long the program waits around +; them. After the gimbal reports standstill it holds still this long before +; keeping a frame, so the image is exposed at rest with the auto-exposure +; settled; frame_timeout_ms then abandons the waypoint rather than parking on it. +; With several cameras the scan uses the LONGEST wait across them. +[Camera.rgb.standard] +settle_wait_ms = 1000 +frame_timeout_ms = 2000 + +[Camera.rgb.night] +settle_wait_ms = 2500 +frame_timeout_ms = 5000 + [Serial] ; Motor-controller serial device and baud rate. device = /dev/ttyACM0 diff --git a/deploy.sh b/deploy.sh index 65c1a31..159fa3d 100755 --- a/deploy.sh +++ b/deploy.sh @@ -15,6 +15,9 @@ # ./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 +# +# NOTE: camera profile XMLs under config/camera_profiles/ are device-local and +# are never synced or deleted - export them on the device with the VimbaX Viewer. # ./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) @@ -176,10 +179,15 @@ echo ">> build id: $GIT_REV" 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. Note this -# excludes only the config.ini at the repo root: config/ itself (the example, -# camera-mode files, scan grids) always syncs. +# config.ini, the camera profile files and images/ are device-local (runtime +# config, operator-exported camera settings, captured output) and are +# deliberately NOT mirrored, so a deploy never clobbers them. +# +# The profile XMLs matter especially: they are exported on the DEVICE from the +# VimbaX Viewer, which is where the camera is, so they exist nowhere in this +# repo. Without these excludes `--delete` erases them on the next deploy - which +# it did once, taking a freshly exported standard.xml with it. The README.md in +# that directory IS repo documentation and still 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. @@ -208,6 +216,8 @@ rsync -avz --delete \ --exclude '.deploy.env' \ "${CONFIG_EXCLUDES[@]}" \ --exclude 'config.ini.bak-*' \ + --exclude 'config/camera_profiles/**/*.xml' \ + --exclude 'config/camera_profiles/**/*.txt' \ --exclude 'images/' \ ./ "$REMOTE_HOST:$REMOTE_DIR/" diff --git a/docs/architecture.md b/docs/architecture.md index 4689a59..02a7deb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -133,29 +133,30 @@ which is why `night` at 1 Hz waits 2.5 s where `standard` at 10 Hz waits 1 s. If no fresh frame has arrived by `frame_timeout_ms` past the wait, the waypoint is logged and skipped. A stalled camera costs single images, never the whole sweep. -### Camera modes +### Camera profiles -Everything on the camera comes from a **mode**: one INI file per mode, `/.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. +Everything on the camera comes from a **profile**: a settings XML exported from the VimbaX Viewer, at +`/.xml`. The program hands that file to the Vimba X SDK (`LoadSettings`, restricted to +the export's `RemoteDevice` block) and nothing else writes camera settings. -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 reason is validity, not convenience. GenICam writability is **stateful** — `AcquisitionFrameRate` is +only writable once `AcquisitionFrameRateEnable` is set, `Width`'s maximum depends on binning, +`IntensityAutoPrecedence` takes `Minimizenoise` but not `MinimizeNoise` — so a list of features applied +line by line can be individually plausible and collectively wrong, and a line that fails leaves the camera +quietly on its old value. The Viewer greys out invalid combinations as the operator configures; replaying +its export is how that guarantee is inherited. An earlier design applied a hand-written INI feature list; +it was removed rather than repaired, because the failure mode was silent. -- **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 = …`. +Beside each XML the program writes `.txt`, a generated human-readable record of the same settings. +**It configures nothing** — it exists so a profile can be read and diffed without opening the Viewer. At +start-up the XML is hashed and compared with the hash stored in the record; a mismatch regenerates the +record and logs every setting that changed, so a camera behaving differently from the last run says so +rather than being discovered later. Every `*.xml` in the directory is loaded at start-up, so a profile +nobody uses today still fails at launch rather than the first night someone switches to it. -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. - -Ships with `standard` (10 Hz daytime) and `night` (1 Hz, long exposures, high gain ceiling). Switch live -with `set camera mode `; that stops acquisition, rewrites the features and restarts, because binning, -ROI and pixel format cannot be changed on a running stream. +Each camera has its own `[Camera.]` block and its own profile directory, so profiles are per camera +rather than shared. Switch live with `set camera profile [] `; that stops acquisition, +reloads the XML and restarts, because binning, ROI and pixel format cannot be changed on a running stream. ### Why not judge each frame instead diff --git a/docs/configuration.md b/docs/configuration.md index 370afad..40c32f4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,8 +35,16 @@ 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` | `mode_dir` | string | `config/camera_modes/rgb` | Directory of camera-mode files; a mode named X is `/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 ` | +| `Camera` | `jxl_distance` / `jxl_effort` | | | Defaults inherited by every camera block | +| `Camera.` | `id` | string | — | USB `DEV_` id or GigE IP. Required when enabled | +| `Camera.` | `driver` | `vimba` | `vimba` | Backend. Only `vimba` is implemented | +| `Camera.` | `label` | string | the block name | Output subdirectory and `CamEvent` `cam` field | +| `Camera.` | `profile_dir` | string | — | Directory of profile XMLs; a profile named X is `/X.xml`. A relative path resolves against the directory holding the `config.ini` in use | +| `Camera.` | `profile` | string | `standard` | Profile to start in. Switch live with `set camera profile [] ` | +| `Camera.` | `enabled` | bool | `true` | A disabled camera is not opened and does not affect the scan timing | +| `Camera.` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to that camera's saved images | +| `Camera..` | `settle_wait_ms` | int ≥ 0 | `1000` | Hold after standstill before keeping a frame | +| `Camera..` | `frame_timeout_ms` | int ≥ 0 | `2000` | Abandon the waypoint after this | | `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) | @@ -139,56 +147,47 @@ 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. -### `[Camera]` — camera modes +### `[Camera]` — cameras and profiles -Every on-camera setting lives in a **mode file**, one per mode, at `/.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. +Each camera gets its own `[Camera.]` block. Nothing is implied by position: +the block names the camera, `label` names its output, and `driver` picks the +backend. A camera's *settings* are not here at all. -Two modes ship: +**The settings live in a profile XML exported from the VimbaX Viewer.** A profile +named `X` is `/X.xml`, and the program hands that file straight to +the Vimba X SDK. There is no other path by which settings reach the camera, and a +missing XML is a startup error rather than a fallback. -| Mode | Rate | Intent | -|---|---|---| -| `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 | +That indirection exists because GenICam validity is **stateful**: +`AcquisitionFrameRate` is only writable once `AcquisitionFrameRateEnable` is set, +`Width`'s maximum depends on binning, and `IntensityAutoPrecedence` accepts +`Minimizenoise` but not `MinimizeNoise`. The Viewer greys out invalid +combinations as you configure; a hand-written list has no such guardrail, and a +line that silently fails leaves the camera on its previous value. -**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. +**To change a camera's settings:** open the Viewer, configure, save the settings +XML over `.xml`, restart. -Order and repetition are deliberate and preserved: +**The `.txt` beside each XML is generated and configures nothing.** It is a +human-readable record of the same settings so a profile can be read and diffed +without opening the Viewer. On startup the program hashes the XML and compares it +with the hash recorded in the `.txt`; if they differ it regenerates the record +and logs a warning naming every setting that moved: -- **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 = …`. +``` +[WARN ] camera 'rgb' profile 'standard': settings XML changed, regenerating standard.txt +[WARN ] AcquisitionFrameRate 3.399707 -> 10 +[WARN ] ExposureAutoMax 50000 -> 15000 +``` -**Error handling.** A feature this camera does not expose logs `camera: no such feature ''` 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. +**Timing is per camera and per profile**, in `[Camera..]`. These +are not camera settings — they are how long the program waits around them. With +several cameras the scan uses the **longest** `settle_wait_ms` across the enabled +ones, since the gimbal must stand still for all of them. -**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. +Every `*.xml` in `profile_dir` is loaded at start-up, so a profile nobody is +using today still fails at launch rather than the first time someone switches to +it. ### `[Env]` — SHT41 ambient temperature/humidity sensor diff --git a/docs/known-issues.md b/docs/known-issues.md index 9fbbe96..f002443 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -29,7 +29,7 @@ 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 | **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. | +| 15 | **Camera profiles unverified on a lit scene** | The `standard`/`night` profiles must be exported from the VimbaX Viewer (see `config/camera_profiles/rgb/README.md`) and have not been measured against real light. Confirm on the rig that `LoadSettings` succeeds, 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. | @@ -107,10 +107,10 @@ scripts for this are worth keeping; note that wrapping them in `timeout` SIGTERM ## Possible follow-ups (not done) -- **A mode file cannot be reloaded without a restart.** `set camera mode ` 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. +- **A profile cannot be reloaded without a restart.** `set camera profile ` re-applies a profile from + the copy loaded at start-up, so re-exporting the XML while the program runs has no effect. Tuning on the + rig therefore costs a restart per export. Re-reading on switch would fix it, at the price of a failure + mode (a mid-session unreadable export) 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`, diff --git a/docs/modules-reference.md b/docs/modules-reference.md index 5650fd9..d7adbe9 100644 --- a/docs/modules-reference.md +++ b/docs/modules-reference.md @@ -20,7 +20,9 @@ Per-file reference for the refactored tree, plus the shared data structures. | [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/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/CameraProfile.h](../include/fgc/CameraProfile.h), [src/core/CameraProfile.cpp](../src/core/CameraProfile.cpp) | `loadCameraProfile` / `loadCameraProfiles`: one VimbaX settings XML per profile. Hashes the XML, regenerates the `.txt` record when it changes, and reports which settings moved. A missing XML throws — the record is never a fallback | +| [include/fgc/CameraSettingsXml.h](../include/fgc/CameraSettingsXml.h), [src/core/CameraSettingsXml.cpp](../src/core/CameraSettingsXml.cpp) | Parses the Viewer export's `` features (ordered, selector-aware), renders the human-readable `.txt` record, reads one back, and diffs two settings lists. Used for display and change detection only — never to configure a camera | +| [include/fgc/Hash.h](../include/fgc/Hash.h), [src/core/Hash.cpp](../src/core/Hash.cpp) | FNV-1a 64 over a string or a file, with an algorithm-prefixed label. Change detection for the profile records, deliberately not a crypto hash | | [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) | @@ -48,7 +50,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): free-running stream + keep-latest, restart-on-stall, and a type-dispatched feature applier that writes a `CameraMode` verbatim | `WITH_VIMBA` | +| [src/camera/VimbaCameraSource.cpp](../src/camera/VimbaCameraSource.cpp) | `ICameraSource` over Vimba X (pImpl): free-running stream + keep-latest, restart-on-stall, and per-camera profiles applied with the SDK's `LoadSettings` on the Viewer's settings XML | `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 | @@ -75,8 +77,14 @@ Per-file reference for the refactored tree, plus the shared data structures. ### `MotorTelemetry` / `AxisTelemetry` ([IMotorController.h](../include/fgc/IMotorController.h)) Per-axis `yaw` and `pitch` segments (`pitch_present` flag), each an `AxisTelemetry`: `state` (`AxisState` B/R/H/A/E), `xactual`, `xenc` (encoder counts), `drv_status`, `sg`/`cs`/`pwm`, and flags -`standstill`/`stall`/`overtemp`/`endstop_l`/`endstop_r`, with `moving()`/`ready()` helpers. Parsed from the -firmware `ST Y:...[ P:...]` line; degrees are derived via `Geometry`. +`standstill`/`stall`/`deviation`/`overtemp`/`endstop_l`/`endstop_r`, with `moving()`/`ready()` helpers. +Parsed from the firmware `ST Y:...[ P:...]` line; degrees are derived via `Geometry`. + +`deviation` (flag `d`) is the TMC5160's hardware encoder-deviation latch — set when `|xactual − xenc|` +exceeds `ENC_DEVIATION`, i.e. the shaft is not where it was commanded to be. It is the usable stall signal +on this rig and is what the TUI's STALL badge shows. `stall` (flag `s`) is StallGuard2, which is inert +because production runs StealthChop; it is still parsed in case that changes. The latch is cleared by +homing or `gimbal reset`, so a lit badge means "position untrustworthy since the last home". ### `ControlCommand` ([IControlChannel.h](../include/fgc/IControlChannel.h)) `control_code` (0 = scan-grid sweep, 1 = directed to `target_HDG`) + `target_heading`, each with an diff --git a/docs/roadmap.md b/docs/roadmap.md index d3fb413..a0fb942 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -35,7 +35,7 @@ Keep entries dated; move shipped items into the reference docs. 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 `). A third mode for the transition, and + chosen by hand (`set camera profile `). A third profile 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, diff --git a/include/fgc/CameraMode.h b/include/fgc/CameraMode.h deleted file mode 100644 index 900ed84..0000000 --- a/include/fgc/CameraMode.h +++ /dev/null @@ -1,42 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -namespace fgc { - -// One named camera mode, loaded from /.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> 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 loadCameraModes(const std::string& dir); - -} // namespace fgc diff --git a/include/fgc/CameraProfile.h b/include/fgc/CameraProfile.h new file mode 100644 index 0000000..00e9577 --- /dev/null +++ b/include/fgc/CameraProfile.h @@ -0,0 +1,50 @@ +#pragma once + +#include "fgc/CameraSettingsXml.h" + +#include +#include +#include + +namespace fgc { + +// One named camera profile: a VimbaX Viewer settings export, plus the generated +// record beside it. +// +// A profile named X is /X.xml, and X.txt is a human-readable record +// of what that XML contains. Only the XML is ever applied - the program hands +// the path to the Vimba X SDK, so the camera receives exactly the combination +// the Viewer validated. `settings` is parsed for display and diffing, never to +// configure anything. +struct CameraProfile { + std::string name; // filename stem + std::string xml_path; // what actually gets applied + std::string txt_path; // the generated record + CameraSettings settings; // parsed from the XML, for reading and diffing +}; + +// What a load did, so the caller can report it. `first_run` distinguishes "there was no record to compare against" from "the +// settings changed": on a first load every setting would otherwise be reported +// as new, which for a real export is thousands of lines and no information. +struct ProfileLoadResult { + bool regenerated = false; + bool first_run = false; // no previous record existed + std::vector changes; // empty when first_run +}; + +// Load a profile: hash the XML, compare against the hash recorded in the .txt, +// and regenerate the record when they differ. +// +// Throws std::runtime_error if the XML is missing or unreadable. There is +// deliberately no fallback to the .txt: it is a record, not a configuration +// source, and applying one would defeat the point of the GUI validating the XML. +CameraProfile loadCameraProfile(const std::string& profile_dir, const std::string& name, + ProfileLoadResult& result); + +// Every *.xml in `dir`, keyed by profile name. Throws if the directory is +// missing or holds no profiles, so a typo in a profile nobody is using today +// still fails at startup rather than the first time someone switches to it. +std::map loadCameraProfiles( + const std::string& dir, std::map& results); + +} // namespace fgc diff --git a/include/fgc/CameraSettingsXml.h b/include/fgc/CameraSettingsXml.h new file mode 100644 index 0000000..fda3f9a --- /dev/null +++ b/include/fgc/CameraSettingsXml.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include + +namespace fgc { + +// One camera setting as the VimbaX Viewer exported it. +struct SettingEntry { + std::string name; + std::string value; + std::string selector; // "" unless the Viewer nested it in a +}; + +// The settings a Viewer XML holds, in file order. +// +// These are NEVER applied: the program hands the XML itself to the Vimba X SDK, +// which is what guarantees the camera gets exactly the combination the GUI +// validated. This parse exists only to render a human-readable record and to +// diff one export against the previous one. +struct CameraSettings { + std::string model; // + std::string device_id; + std::vector entries; // features, in order +}; + +// Parse a VimbaX Viewer settings export. Only the block is read: +// the /stream sections describe the host's transport layer, not the +// sensor. Throws std::runtime_error naming the problem if the file is missing or +// does not look like a Viewer export. +CameraSettings parseCameraSettingsXml(const std::string& path); + +// Render the human-readable record written beside the XML. `xml_name` and +// `hash_label` go in the header so the file says what it came from. +std::string renderSettingsRecord(const CameraSettings& s, const std::string& xml_name, + const std::string& hash_label); + +// Read back a record written by renderSettingsRecord. Returns the recorded hash +// label (empty if the file is absent or has none) and the settings it listed, so +// a regeneration can report what actually changed. +struct SettingsRecord { + std::string hash_label; + std::vector entries; +}; +SettingsRecord readSettingsRecord(const std::string& path); + +// Human-readable difference between two settings lists, one line per change: +// "AcquisitionFrameRate 10 -> 8", "+ Gamma 1.2 (new)", "- Sharpness (removed)". +// Empty when they match. +std::vector diffSettings(const std::vector& before, + const std::vector& after); + +} // namespace fgc diff --git a/include/fgc/CaptureScheduler.h b/include/fgc/CaptureScheduler.h index 10eafac..40afc63 100644 --- a/include/fgc/CaptureScheduler.h +++ b/include/fgc/CaptureScheduler.h @@ -42,7 +42,7 @@ 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 + // Per-camera-profile timing (see CameraProfile): 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); diff --git a/include/fgc/Config.h b/include/fgc/Config.h index 80e075b..a1944fd 100644 --- a/include/fgc/Config.h +++ b/include/fgc/Config.h @@ -42,25 +42,48 @@ struct SerialConfig { unsigned int baud = 115200; }; +// Per-profile program behaviour, from [Camera..]. These are not +// camera settings - the camera's own settings live entirely in the profile XML - +// but timing the program must observe around them. +struct CameraProfileTiming { + int settle_wait_ms = 1000; // hold after standstill before keeping a frame + int frame_timeout_ms = 2000; // give up on the waypoint after this +}; + +// One camera, from a [Camera.] block. Nothing is implied by position: the +// block names the camera, `label` names its output, `driver` picks the backend. +struct CameraSpec { + std::string name; // the block suffix, e.g. "rgb" + bool enabled = true; + std::string driver = "vimba"; // only "vimba" is implemented + std::string id; // USB DEV_ id or GigE IP + std::string label; // output subdir + CamEvent "cam"; defaults to name + std::string profile_dir; // relative => resolved next to config.ini + std::string profile = "standard"; // which profile to start in + int rotation_deg = 0; // clockwise; 0/90/180/270 + + // Encoding, defaulted from [Camera] and overridable per camera. + double jxl_distance = 0.8; + int jxl_effort = 4; + + std::map timing; // by profile name + + // Timing for `profile`, or the defaults when the block does not name it. + CameraProfileTiming timingFor(const std::string& profile_name) const; +}; + struct CameraConfig { - std::vector ids; // GigE IP or USB DEV_ id, in order - std::vector labels = {"RGB", "ACR", "NIR"}; // index -> output subfolder + std::vector cameras; // in config order; empty is a config error + // [Camera] defaults, inherited by each block unless it overrides them. + double jxl_distance = 0.8; + int jxl_effort = 4; - // On-camera imaging settings live in mode files, one per mode: a mode named X - // is /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 - int jxl_effort = 4; // libjxl effort 1..9 - // Clockwise rotation applied to saved images (0/90/180/270). 0 = as-sensed. - int rotation_deg = 0; + // Enabled cameras only. + std::vector active() const; + // The gimbal must stand still long enough for EVERY active camera, so the + // scheduler takes the longest wait rather than the first camera's. + int maxSettleWaitMs() const; + int maxFrameTimeoutMs() const; }; struct PathsConfig { diff --git a/include/fgc/Hash.h b/include/fgc/Hash.h new file mode 100644 index 0000000..6b34717 --- /dev/null +++ b/include/fgc/Hash.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +namespace fgc { + +// FNV-1a 64-bit, for detecting that a file changed. +// +// Deliberately not a cryptographic hash: the only question asked of it is "is +// this the same settings XML the record was generated from?", which is change +// detection, not tamper resistance. A crypto dependency would be a poor trade +// for that, and this is ten lines that need no third party. +// +// Digests are rendered with an algorithm prefix ("fnv1a64:<16 hex digits>") so a +// stored digest can never be silently misread if the algorithm is ever changed. +uint64_t fnv1a64(const std::string& data); +std::string hashLabel(uint64_t digest); // -> "fnv1a64:9d3c1a77b21e4f80" + +// Hash a file's bytes. Throws std::runtime_error if it cannot be read - callers +// use this to decide whether to regenerate, so silently returning 0 would make a +// missing file look like an unchanged one. +uint64_t fnv1a64File(const std::string& path); + +} // namespace fgc diff --git a/include/fgc/ICameraSource.h b/include/fgc/ICameraSource.h index 9aef171..c5622f8 100644 --- a/include/fgc/ICameraSource.h +++ b/include/fgc/ICameraSource.h @@ -1,6 +1,6 @@ #pragma once -#include "fgc/CameraMode.h" +#include "fgc/CameraProfile.h" #include #include @@ -67,9 +67,13 @@ public: // yet, and the caller simply retries. virtual bool trigger() = 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; } + // Apply a profile to one camera, identified by its index in the order the + // source opened them (the same index Frame::cam_id carries). The backend + // loads the profile's settings XML through its SDK - see CameraProfile.h. + // Default: no-op, for sources with nothing to configure. + virtual bool setProfile(int /*camera_index*/, const CameraProfile& /*profile*/) { + return false; + } virtual void setFrameCallback(FrameCallback cb) = 0; diff --git a/include/fgc/IMotorController.h b/include/fgc/IMotorController.h index 075f213..ef3d590 100644 --- a/include/fgc/IMotorController.h +++ b/include/fgc/IMotorController.h @@ -19,7 +19,13 @@ struct AxisTelemetry { int cs = 0; // CS_ACTUAL int pwm = 0; // PWM_SCALE_SUM bool standstill = false; // flag 'S' (DRV_STST) - bool stall = false; // flag 's' + bool stall = false; // flag 's' (StallGuard) - inert in StealthChop, see below + // Flag 'd': the TMC's hardware encoder-deviation latch, set when + // |xactual - xenc| exceeded ENC_DEVIATION. This is the usable stall signal on + // this rig - StallGuard needs SpreadCycle, which production does not run. + // Latched until the axis is homed or reset, so it means "position + // untrustworthy", not "stalling right now". + bool deviation = false; bool overtemp = false; // flag 'o'/'O' bool endstop_l = false; // flag 'L' bool endstop_r = false; // flag 'R' diff --git a/include/fgc/ImagePipeline.h b/include/fgc/ImagePipeline.h index b2a802c..80806b9 100644 --- a/include/fgc/ImagePipeline.h +++ b/include/fgc/ImagePipeline.h @@ -28,16 +28,23 @@ struct Orientation { // VimbaHandler::SaveImage so any camera source (real or mock) can feed it. class ImagePipeline { public: + // Per-camera output settings. Label, rotation and encoding are all properties + // of the individual camera now, so they travel together and are indexed by + // Frame::cam_id rather than assumed uniform across the rig. + struct CameraOutput { + std::string label; // output subdir + CamEvent "cam" + int rotation_deg = 0; // clockwise; 0/90/180/270 + double jxl_distance = 0.8; // 0 = lossless + int jxl_effort = 4; + }; + struct Params { - std::string output_dir; - std::vector labels = {"RGB", "ACR", "NIR"}; // by camera index - std::string tower; - double jxl_distance = 2.0; // 0 = lossless - int jxl_effort = 3; - int rotation_deg = 0; // clockwise; 0/90/180/270 - bool demo = false; // copy demo_image instead of encoding - std::string demo_image = "test_smoke.jxl"; - bool display = false; // show an OpenCV preview window + std::string output_dir; + std::vector cameras; // by camera index, in open order + std::string tower; + bool demo = false; // copy demo_image instead of encoding + std::string demo_image = "test_smoke.jxl"; + bool display = false; // show an OpenCV preview window }; // orientation: returns the current gimbal yaw+pitch (degrees) for CamEvent. @@ -51,8 +58,10 @@ public: void submit(const Frame& frame); // Runtime-adjustable encoder settings (console "set camera ..." commands). - void setDistance(double d) { params_.jxl_distance = d; } - void setEffort(int e) { params_.jxl_effort = e; } + // These apply to every camera: they exist for quick experiments, and asking + // which camera on every keystroke would make that worse, not better. + void setDistance(double d) { for (auto& c : params_.cameras) c.jxl_distance = d; } + void setEffort(int e) { for (auto& c : params_.cameras) c.jxl_effort = e; } void setDisplay(bool on) { params_.display = on; } // Last CamEvent published (camera label, heading/pitch, timestamp), for the @@ -65,7 +74,9 @@ public: private: void run(); void process(const Frame& frame); - std::string labelFor(int cam_id) const; + // Output settings for a camera index, or sane defaults if the index is out of + // range (a source delivering more cameras than were configured). + const CameraOutput& outputFor(int cam_id) const; IControlChannel& channel_; std::function orientation_; diff --git a/include/fgc/TelemetryParser.h b/include/fgc/TelemetryParser.h index 95df489..edb237f 100644 --- a/include/fgc/TelemetryParser.h +++ b/include/fgc/TelemetryParser.h @@ -12,7 +12,8 @@ namespace fgc { // ST Y:,,,,,,, [P:...] // // where is B/R/H/A/E and is an optional run of status letters -// (S=standstill, s=stall, o/O=overtemp, L/R=endstops, e=eeprom). Returns nullopt +// (S=standstill, s=stall, d=encoder deviation, o/O=overtemp, L/R=endstops, +// e=eeprom). Returns nullopt // for any line that is not a well-formed `ST` line (acks and other async output // like OK/ERR/DG/DUMP are handled by the caller). A missing/garbled axis segment // yields nullopt rather than partial data. diff --git a/include/fgc/VimbaCameraSource.h b/include/fgc/VimbaCameraSource.h index ad2657c..ffddf04 100644 --- a/include/fgc/VimbaCameraSource.h +++ b/include/fgc/VimbaCameraSource.h @@ -1,35 +1,37 @@ #pragma once -#include "fgc/CameraMode.h" +#include "fgc/CameraProfile.h" #include "fgc/Config.h" #include "fgc/ICameraSource.h" #include +#include namespace fgc { // Real camera source backed by the Allied Vision Vimba X SDK (VmbCPP). Opens the -// cameras named in CameraConfig; every on-camera setting comes from a CameraMode, -// which is just a list of Vimba features written verbatim. +// cameras named by their CameraSpec and configures each one by handing its +// profile's settings XML to the SDK - the XML the VimbaX Viewer wrote, so the +// camera receives exactly the combination the GUI validated. // -// The camera free-runs continuously at the mode's AcquisitionFrameRate with its -// own auto-exposure tracking the light. trigger() hands over the latest complete +// The camera free-runs continuously at the rate the profile sets, 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); + // specs and profiles are index-aligned and in open order: cameras[i] is + // configured by profiles[i], so a camera never inherits its neighbour's + // settings. + VimbaCameraSource(std::vector specs, std::vector profiles); ~VimbaCameraSource() override; - // 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 setMode(const CameraMode& mode) override; + bool setProfile(int camera_index, const CameraProfile& profile) override; void setFrameCallback(FrameCallback cb) override; int cameraCount() const override; std::vector deviceInfo() override; diff --git a/include/fgc/mock/MockCameraSource.h b/include/fgc/mock/MockCameraSource.h index fcc3752..6c3eb6a 100644 --- a/include/fgc/mock/MockCameraSource.h +++ b/include/fgc/mock/MockCameraSource.h @@ -10,9 +10,9 @@ 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. 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. +// and delivers it to the frame callback. setProfile() only records the profile +// name - there is no sensor to configure - which is enough to exercise profile +// 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) @@ -37,9 +37,9 @@ public: void setFrameCallback(FrameCallback cb) override { callback_ = std::move(cb); } int cameraCount() const override { return count_; } - bool setMode(const CameraMode& mode) override { - mode_ = mode.name; - LOG_INFO << "[mock] camera mode -> " << mode_; + bool setProfile(int camera_index, const CameraProfile& profile) override { + profile_ = profile.name; + LOG_INFO << "[mock] camera " << camera_index << " profile -> " << profile_; return true; } @@ -103,7 +103,7 @@ private: uint32_t height_; bool started_ = false; long long frames_ = 0; - std::string mode_; + std::string profile_; FrameCallback callback_; }; diff --git a/include/fgc/mock/MockMotorController.h b/include/fgc/mock/MockMotorController.h index dc0c040..ff0eec8 100644 --- a/include/fgc/mock/MockMotorController.h +++ b/include/fgc/mock/MockMotorController.h @@ -29,6 +29,14 @@ public: if (verb == "HOME") { homed_ = true; + deviation_ = false; // homing clears the latch, as on the real driver + } else if (verb == "STALL") { + // Test hook: fake the hardware encoder-deviation latch so the STALL + // badge is reachable without jamming a real axis. Latches like the + // real one - only HOME/RESET clears it. + deviation_ = true; + } else if (verb == "RESET") { + deviation_ = false; } else if (verb == "MOVE") { std::string a; in >> a; @@ -139,6 +147,7 @@ private: } a.xactual = a.xenc; a.state = homed_ ? AxisState::Ready : AxisState::Reset; + a.deviation = deviation_; } static void tryParse(const std::string& s, long& out) { @@ -153,6 +162,7 @@ private: long yaw_target_ = 0; long pitch_target_ = 0; bool homed_ = false; + bool deviation_ = false; // faked encoder-deviation latch (see STALL above) std::string dump_; std::string diag_; unsigned diag_seq_ = 0; diff --git a/include/fgc/ui/UiSnapshot.h b/include/fgc/ui/UiSnapshot.h index f78d768..933a315 100644 --- a/include/fgc/ui/UiSnapshot.h +++ b/include/fgc/ui/UiSnapshot.h @@ -33,7 +33,8 @@ struct AxisView { unsigned drv_status = 0; // DRV_STATUS register bool moving = false; bool standstill = false; - bool stall = false; + bool stall = false; // StallGuard ('s') - inert on this rig + bool deviation = false; // encoder-deviation latch ('d') - the real stall signal bool overtemp = false; bool endstop_l = false; bool endstop_r = false; @@ -97,16 +98,28 @@ struct ScanWaypointView { }; // Static imaging + encoding config (from CameraConfig), for the expanded view. -struct CameraConfigView { - bool mock = false; - std::vector ids; - // Active camera mode (see CameraMode) and the ones available to switch to. - std::string mode, mode_desc; +// One configured camera, as the expanded view shows it. Everything here is +// explicit per camera - identity is never implied by position. +struct CameraView { + std::string name; // the [Camera.] block + std::string label; // output subdir + CamEvent "cam" + std::string id; // USB DEV_ id or GigE IP + std::string driver; + bool enabled = true; + std::string profile; // active profile + std::vector profiles_available; + std::string profile_dir; + std::string profile_xml; // the file actually applied to the camera int settle_wait_ms = 0; - std::vector modes_available; - double jxl_distance = 0.0; int jxl_effort = 0; - int rotation_deg = 0; // clockwise; 0 = none - std::string output_dir; + int rotation_deg = 0; + double jxl_distance = 0.0; + int jxl_effort = 0; +}; + +struct CameraConfigView { + bool mock = false; + std::vector cameras; + std::string output_dir; }; // Live per-camera identity/telemetry/stats (from ICameraSource::deviceInfo()). diff --git a/src/camera/ImagePipeline.cpp b/src/camera/ImagePipeline.cpp index 5ce7c25..3b04780 100644 --- a/src/camera/ImagePipeline.cpp +++ b/src/camera/ImagePipeline.cpp @@ -65,10 +65,13 @@ void ImagePipeline::run() { } } -std::string ImagePipeline::labelFor(int cam_id) const { - if (cam_id >= 0 && cam_id < static_cast(params_.labels.size())) - return params_.labels[cam_id]; - return "cam" + std::to_string(cam_id); +const ImagePipeline::CameraOutput& ImagePipeline::outputFor(int cam_id) const { + if (cam_id >= 0 && cam_id < static_cast(params_.cameras.size())) + return params_.cameras[cam_id]; + // A source delivering an index we were not told about still has to write + // somewhere identifiable rather than colliding with camera 0. + static const CameraOutput fallback{"cam?", 0, 0.8, 4}; + return fallback; } void ImagePipeline::process(const Frame& frame) { @@ -77,11 +80,13 @@ void ImagePipeline::process(const Frame& frame) { return; } + const CameraOutput& out = outputFor(frame.cam_id); + const int type = frame.channels == 1 ? CV_8UC1 : CV_8UC3; cv::Mat src(frame.height, frame.width, type, const_cast(frame.data.data())); cv::Mat rotated; int code = 0; - bool rotate = rotateCode(params_.rotation_deg, code); + bool rotate = rotateCode(out.rotation_deg, code); if (rotate) cv::rotate(src, rotated, code); // src only wraps frame.data, so an unrotated frame costs no copy at all. const cv::Mat& img = rotate ? rotated : src; @@ -93,7 +98,8 @@ void ImagePipeline::process(const Frame& frame) { cv::waitKey(10); } - const std::string label = labelFor(frame.cam_id); + const std::string label = out.label.empty() ? ("cam" + std::to_string(frame.cam_id)) + : out.label; const fs::path dir = fs::path(params_.output_dir) / label; std::error_code ec; fs::create_directories(dir, ec); @@ -108,8 +114,8 @@ void ImagePipeline::process(const Frame& frame) { if (ec) LOG_ERROR << "Demo copy failed: " << ec.message(); } else { bool ok = JpegXlEncoder::encodeToFile(file.string(), img.data, img.cols, img.rows, - frame.channels, params_.jxl_distance, - params_.jxl_effort); + frame.channels, out.jxl_distance, + out.jxl_effort); if (!ok) { LOG_ERROR << "Failed to encode " << file.string(); return; diff --git a/src/camera/VimbaCameraSource.cpp b/src/camera/VimbaCameraSource.cpp index 8e54728..2beeba9 100644 --- a/src/camera/VimbaCameraSource.cpp +++ b/src/camera/VimbaCameraSource.cpp @@ -134,80 +134,50 @@ std::string getString(const CameraPtr& cam, const char* name) { return {}; } -bool parseBool(const std::string& v) { - return v == "1" || v == "true" || v == "True" || v == "yes" || v == "on"; -} +// Apply a profile by handing its settings XML to the SDK. +// +// This replaces a hand-rolled feature applier, and the reason is validity rather +// than brevity: GenICam writability is stateful (AcquisitionFrameRate is only +// writable once AcquisitionFrameRateEnable is set, Width's maximum depends on +// binning, and so on), so a text list applied line by line can be individually +// plausible and collectively wrong. The XML comes out of the VimbaX Viewer, +// which greys out invalid combinations as the operator configures, and +// LoadSettings replays exactly that. Nothing else may write settings. +bool applyProfile(const CameraPtr& cam, const CameraProfile& profile) { + VmbFeaturePersistSettings settings{}; + settings.persistType = VmbFeaturePersistNoLUT; + // RemoteDevice only: the transport-layer and stream sections of the export + // describe the host that produced it, not the sensor. + settings.modulePersistFlags = VmbModulePersistFlagsRemoteDevice; + settings.maxIterations = 10; // features can need several passes to settle + settings.loggingLevel = VmbLogLevelWarn; -// 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; + const VmbErrorType rc = SP_ACCESS(cam)->LoadSettings(profile.xml_path.c_str(), &settings); + if (rc != VmbErrorSuccess) { + LOG_ERROR << "camera: applying profile '" << profile.name << "' from " + << profile.xml_path << " failed (error " << rc << ')'; + return false; } - - VmbFeatureDataType type = VmbFeatureDataUnknown; - if (f->GetDataType(type) != VmbErrorSuccess) { - LOG_WARN << "camera: cannot read type of feature '" << name << "' (skipped)"; - return; - } - - VmbErrorType rc = VmbErrorSuccess; - try { - switch (type) { - case VmbFeatureDataInt: - rc = f->SetValue(static_cast(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)"; + LOG_INFO << "camera profile '" << profile.name << "' applied from " << profile.xml_path + << " (" << profile.settings.entries.size() << " settings)"; + return true; } } // namespace struct VimbaCameraSource::Impl { - explicit Impl(CameraConfig cfg) : config(std::move(cfg)), sys(VmbSystem::GetInstance()) {} + Impl(std::vector s, std::vector p) + : specs(std::move(s)), profiles(std::move(p)), sys(VmbSystem::GetInstance()) {} struct Identity { std::string id, model, serial; }; struct Telemetry { double exposure_us = 0, gain_db = 0, temperature_c = 0, actual_fps = 0; }; - CameraConfig config; - CameraMode mode; // applied at start() and by setMode() + // One spec and one profile per camera, index-aligned and in open order, so a + // camera's settings are never those of the camera next to it. + std::vector specs; + std::vector profiles; VmbSystem& sys; std::vector cameras; std::vector observers; @@ -247,8 +217,9 @@ struct VimbaCameraSource::Impl { }; -VimbaCameraSource::VimbaCameraSource(CameraConfig config) - : impl_(std::make_unique(std::move(config))) {} +VimbaCameraSource::VimbaCameraSource(std::vector specs, + std::vector profiles) + : impl_(std::make_unique(std::move(specs), std::move(profiles))) {} VimbaCameraSource::~VimbaCameraSource() { try { @@ -262,19 +233,28 @@ VimbaCameraSource::~VimbaCameraSource() { void VimbaCameraSource::open() { if (impl_->sys.Startup() != VmbErrorSuccess) throw std::runtime_error("Could not start Vimba X API"); - for (const auto& id : impl_->config.ids) { + for (const auto& spec : impl_->specs) { + const std::string& id = spec.id; CameraPtr cam; if (impl_->sys.GetCameraByID(id.c_str(), cam) != VmbErrorSuccess) - throw std::runtime_error("Camera not found: " + id); - if (cam->Open(VmbAccessModeFull) != VmbErrorSuccess) - throw std::runtime_error("Could not open camera: " + id); + throw std::runtime_error("Camera '" + spec.name + "' not found: " + id); + if (const VmbErrorType rc = cam->Open(VmbAccessModeFull); rc != VmbErrorSuccess) { + std::string why; + if (rc == VmbErrorInvalidAccess || rc == VmbErrorBusy) + why = " - another process has it open (is the VimbaX Viewer still running?)"; + else if (rc == VmbErrorNotFound) + why = " - it disappeared between discovery and open (USB reset?)"; + throw std::runtime_error("Could not open camera '" + spec.name + "': " + id + + " (Vimba error " + std::to_string(rc) + ")" + why); + } impl_->cameras.push_back(cam); // Per-camera stats (persist across restart) + identity (read once here). impl_->stats.push_back(std::make_unique()); impl_->telemetry.emplace_back(); impl_->identity.push_back({id, getString(cam, "DeviceModelName"), getString(cam, "DeviceSerialNumber")}); - LOG_INFO << "Opened camera " << id << " (" << impl_->identity.back().model << ')'; + LOG_INFO << "Opened camera '" << spec.name << "' " << id << " (" + << impl_->identity.back().model << ')'; } } @@ -284,30 +264,36 @@ void VimbaCameraSource::close() { impl_->sys.Shutdown(); } -void VimbaCameraSource::setInitialMode(CameraMode mode) { impl_->mode = std::move(mode); } - void VimbaCameraSource::start() { - for (auto& cam : impl_->cameras) applyMode(cam, impl_->mode); + for (size_t i = 0; i < impl_->cameras.size() && i < impl_->profiles.size(); ++i) + applyProfile(impl_->cameras[i], impl_->profiles[i]); impl_->startAcquisition(); impl_->started = true; } -bool VimbaCameraSource::setMode(const CameraMode& mode) { - impl_->mode = mode; +bool VimbaCameraSource::setProfile(int camera_index, const CameraProfile& profile) { + const size_t idx = static_cast(camera_index); + if (camera_index < 0 || idx >= impl_->cameras.size() || idx >= impl_->profiles.size()) { + LOG_WARN << "camera: setProfile for unknown camera index " << camera_index; + return false; + } + + impl_->profiles[idx] = profile; 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. + // profile may change any of them, so always cycle the stream. Profile 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); + const bool ok = applyProfile(impl_->cameras[idx], profile); impl_->startAcquisition(); + if (!ok) return false; } catch (const std::exception& e) { - LOG_ERROR << "camera: applying mode '" << mode.name << "' failed: " << e.what(); + LOG_ERROR << "camera: applying profile '" << profile.name << "' failed: " << e.what(); return false; } - // The next trigger() must not hand back a frame exposed under the old mode. + // The next trigger() must not hand back a frame exposed under the old profile. impl_->last_captured_ts = nowMs(); return true; } diff --git a/src/core/Application.cpp b/src/core/Application.cpp index 3c86a9a..afcb725 100644 --- a/src/core/Application.cpp +++ b/src/core/Application.cpp @@ -7,7 +7,7 @@ #include "fgc/DumpParser.h" #include "fgc/HelpText.h" #include "fgc/Paths.h" -#include "fgc/CameraMode.h" +#include "fgc/CameraProfile.h" #include "fgc/ICameraSource.h" #include "fgc/IControlChannel.h" #include "fgc/IEnvSensor.h" @@ -183,9 +183,10 @@ struct Application::Impl { std::unique_ptr motor; std::unique_ptr camera; // Camera modes, loaded once at startup and kept so a live switch needs no disk - // access. `camera_mode` is the active one. - std::map camera_modes; - std::string camera_mode; + // access. Keyed by camera name, then profile name; `active_profile` tracks + // which profile each camera is currently on. + std::map> camera_profiles; + std::map active_profile; std::unique_ptr imu; std::unique_ptr env; std::unique_ptr pipeline; @@ -296,49 +297,113 @@ struct Application::Impl { mock = true; } #endif + const auto active = cfg.camera.active(); if (mock) { - int count = cfg.camera.ids.empty() ? 1 : static_cast(cfg.camera.ids.size()); - return std::make_unique(count); + return std::make_unique( + active.empty() ? 1 : static_cast(active.size())); } #if FGC_WITH_VIMBA - auto cam = std::make_unique(cfg.camera); - cam->setInitialMode(camera_modes.at(camera_mode)); - return cam; + std::vector specs; + std::vector profiles; + for (const auto* c : active) { + specs.push_back(*c); + profiles.push_back(camera_profiles.at(c->name).at(active_profile.at(c->name))); + } + return std::make_unique(std::move(specs), std::move(profiles)); #else return std::make_unique(1); #endif } - // 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 + ")"); + // Load every profile of every camera up front. Two reasons: a profile nobody + // is using today still fails at startup rather than the first time someone + // switches to it, and a settings XML edited in the Viewer is detected and + // reported here rather than silently taking effect. + void loadProfiles() { + if (cfg.camera.cameras.empty()) + throw std::runtime_error("no cameras configured (expected at least one " + "[Camera.] block)"); + + for (const auto& c : cfg.camera.cameras) { + if (!c.enabled) continue; + std::map results; + auto profiles = fgc::loadCameraProfiles(c.profile_dir, results); + + if (profiles.find(c.profile) == profiles.end()) { + std::string known; + for (const auto& [name, p] : profiles) known += (known.empty() ? "" : ", ") + name; + throw std::runtime_error("Camera." + c.name + ".profile '" + c.profile + + "' has no " + c.profile + ".xml in " + c.profile_dir + + " (available: " + known + ")"); + } + + LOG_INFO << "camera '" << c.name << "': " << profiles.size() << " profile(s) from " + << c.profile_dir << "; active '" << c.profile << "'"; + for (const auto& [pname, r] : results) { + if (r.first_run) { + // Nothing changed - there was simply no record yet. + LOG_INFO << "camera '" << c.name << "' profile '" << pname + << "': writing " << pname << ".txt (no previous record)"; + continue; + } + // A changed XML is a warning, not an info line: the camera is about + // to behave differently from the last run and nobody edited this + // program. + LOG_WARN << "camera '" << c.name << "' profile '" << pname + << "': settings XML changed, regenerating " << pname << ".txt"; + for (const auto& line : r.changes) LOG_WARN << " " << line; + } + + camera_profiles[c.name] = std::move(profiles); + active_profile[c.name] = c.profile; } - camera_mode = cfg.camera.mode; - LOG_INFO << "camera modes: " << camera_modes.size() << " from " << cfg.camera.mode_dir - << "; active '" << camera_mode << "'"; } - // 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()) { + // Switch one camera's profile by name. With no camera named, applies to the + // single configured camera. Returns false (and warns) if unknown. + bool applyProfile(const std::string& cam_name, const std::string& name) { + auto ci = camera_profiles.find(cam_name); + if (ci == camera_profiles.end()) { std::string known; - for (const auto& [n, m] : camera_modes) known += (known.empty() ? "" : ", ") + n; - LOG_WARN << "unknown camera mode '" << name << "' (available: " << known << ')'; + for (const auto& [n, p] : camera_profiles) known += (known.empty() ? "" : ", ") + n; + LOG_WARN << "unknown camera '" << cam_name << "' (configured: " << 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 << ')'; + auto it = ci->second.find(name); + if (it == ci->second.end()) { + std::string known; + for (const auto& [n, p] : ci->second) known += (known.empty() ? "" : ", ") + n; + LOG_WARN << "unknown profile '" << name << "' for camera '" << cam_name + << "' (available: " << known << ')'; + return false; + } + // The index is the camera's position among the ACTIVE cameras, which is the + // order the source opened them in and the order Frame::cam_id follows. + int index = -1, i = 0; + for (const auto* c : cfg.camera.active()) { + if (c->name == cam_name) { index = i; break; } + ++i; + } + if (index < 0) { + LOG_WARN << "camera '" << cam_name << "' is configured but not enabled"; + return false; + } + if (camera) camera->setProfile(index, it->second); + active_profile[cam_name] = name; + + // The gimbal must stand still long enough for every camera, so recompute + // from all of them rather than from the one that just changed. + if (scheduler) { + int settle = 0, timeout = 0; + for (const auto* c : cfg.camera.active()) { + const auto t = c->timingFor(active_profile.count(c->name) ? active_profile[c->name] + : c->profile); + settle = std::max(settle, t.settle_wait_ms); + timeout = std::max(timeout, t.frame_timeout_ms); + } + scheduler->setSettleWait(settle, timeout); + } + LOG_INFO << "camera '" << cam_name << "' profile -> " << name; return true; } @@ -532,6 +597,7 @@ struct Application::Impl { v.moving = a.moving(); v.standstill = a.standstill; v.stall = a.stall; + v.deviation = a.deviation; v.overtemp = a.overtemp; v.endstop_l = a.endstop_l; v.endstop_r = a.endstop_r; @@ -663,23 +729,36 @@ struct Application::Impl { s.capture.active = scheduler && scheduler->captureActive(); s.capture.image_rate = scheduler ? scheduler->imageRate() : 0.0; s.capture.camera_count = camera ? camera->cameraCount() : 0; - s.capture.labels = cfg.camera.labels; - // Static imaging + encoding config, for the expanded camera view. + s.capture.labels.clear(); + for (const auto* c : cfg.camera.active()) s.capture.labels.push_back(c->label); + // Per-camera configuration, for the expanded camera view. { auto& cc = s.capture.config; - cc.mock = opts.mock_camera.value_or(cfg.features.mock_camera); - cc.ids = cfg.camera.ids; - 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.mock = opts.mock_camera.value_or(cfg.features.mock_camera); + cc.output_dir = cfg.paths.output_dir; + cc.cameras.clear(); + for (const auto& c : cfg.camera.cameras) { + CameraView v; + v.name = c.name; + v.label = c.label; + v.id = c.id; + v.driver = c.driver; + v.enabled = c.enabled; + v.profile_dir = c.profile_dir; + v.rotation_deg = c.rotation_deg; + v.jxl_distance = c.jxl_distance; + v.jxl_effort = c.jxl_effort; + auto ap = active_profile.find(c.name); + v.profile = ap != active_profile.end() ? ap->second : c.profile; + v.settle_wait_ms = c.timingFor(v.profile).settle_wait_ms; + if (auto ci = camera_profiles.find(c.name); ci != camera_profiles.end()) { + for (const auto& [pname, prof] : ci->second) + v.profiles_available.push_back(pname); + if (auto pi = ci->second.find(v.profile); pi != ci->second.end()) + v.profile_xml = pi->second.xml_path; + } + cc.cameras.push_back(std::move(v)); } - 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; - cc.output_dir = cfg.paths.output_dir; } // Live per-camera identity/telemetry/stats. s.capture.devices.clear(); @@ -1532,10 +1611,22 @@ struct Application::Impl { handleTrace(c); } else if (c.verb == "set") { if (c.device == "camera") { - // "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 "; + // "set camera profile night" arrives as option="profile night": + // the parser joins non-numeric trailing tokens into `option`. With + // two words after "profile" the first names the camera. + if (c.option.rfind("profile ", 0) == 0) { + const std::string arg = c.option.substr(8); + const auto sp = arg.find(' '); + if (sp != std::string::npos) + applyProfile(arg.substr(0, sp), arg.substr(sp + 1)); + else if (camera_profiles.size() == 1) + applyProfile(camera_profiles.begin()->first, arg); + else + LOG_WARN << "more than one camera configured: " + "usage: set camera profile "; + } else if (c.option == "profile") { + LOG_WARN << "usage: set camera profile [] "; + } else if (c.option == "jxlq" && c.has_value) pipeline->setDistance(c.value); else if (c.option == "jxle" && c.has_value) pipeline->setEffort(static_cast(c.value)); else if (c.option == "display") pipeline->setDisplay(c.value != 0.0); @@ -1588,7 +1679,7 @@ struct Application::Impl { if (Logger::categories()) LOG_INFO << "wire trace enabled: " << traceNames(Logger::categories()); - loadModes(); // before makeCamera(): the mode is applied at start() + loadProfiles(); // before makeCamera(): the profile is applied at start() channel = makeChannel(); motor = makeMotor(); @@ -1616,12 +1707,12 @@ struct Application::Impl { ImagePipeline::Params pp; pp.output_dir = cfg.paths.output_dir; - pp.labels = cfg.camera.labels; pp.tower = cfg.general.tower_name; pp.demo = opts.demo; - pp.jxl_distance = cfg.camera.jxl_distance; - pp.jxl_effort = cfg.camera.jxl_effort; - pp.rotation_deg = cfg.camera.rotation_deg; + // In open order, matching Frame::cam_id: the source opens the active + // cameras in this order too. + for (const auto* c : cfg.camera.active()) + pp.cameras.push_back({c->label, c->rotation_deg, c->jxl_distance, c->jxl_effort}); // Orientation supplier: convert the per-axis encoder counts to degrees. pipeline = std::make_unique( *channel, @@ -1644,10 +1735,9 @@ struct Application::Impl { LOG_INFO << "scan grid: " << grid.size() << " waypoints"; scheduler = std::make_unique(*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); - } + // Every camera has to have finished by the time the gimbal moves on, so + // the wait is the longest across them, not the first one's. + scheduler->setSettleWait(cfg.camera.maxSettleWaitMs(), cfg.camera.maxFrameTimeoutMs()); motor->start(); if (imu) imu->start(); diff --git a/src/core/CameraMode.cpp b/src/core/CameraMode.cpp deleted file mode 100644 index d379be5..0000000 --- a/src/core/CameraMode.cpp +++ /dev/null @@ -1,95 +0,0 @@ -#include "fgc/CameraMode.h" - -#include -#include -#include -#include - -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(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 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 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 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 diff --git a/src/core/CameraProfile.cpp b/src/core/CameraProfile.cpp new file mode 100644 index 0000000..9cb211a --- /dev/null +++ b/src/core/CameraProfile.cpp @@ -0,0 +1,80 @@ +#include "fgc/CameraProfile.h" + +#include "fgc/Hash.h" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace fgc { + +CameraProfile loadCameraProfile(const std::string& profile_dir, const std::string& name, + ProfileLoadResult& result) { + result = {}; + + CameraProfile p; + p.name = name; + p.xml_path = (fs::path(profile_dir) / (name + ".xml")).string(); + p.txt_path = (fs::path(profile_dir) / (name + ".txt")).string(); + + if (!fs::is_regular_file(p.xml_path)) + throw std::runtime_error("camera profile '" + name + "': " + p.xml_path + + " not found (export it from the VimbaX Viewer)"); + + const std::string label = hashLabel(fnv1a64File(p.xml_path)); + p.settings = parseCameraSettingsXml(p.xml_path); + + // An absent or stale record is regenerated. Reading the old one first is what + // lets the caller report WHICH settings moved, rather than just that the file + // changed - that difference is the whole value of the check. + const SettingsRecord old = readSettingsRecord(p.txt_path); + if (old.hash_label == label) return p; + + result.regenerated = true; + // No previous record means no baseline, not 800 changed settings. + result.first_run = old.hash_label.empty() && old.entries.empty(); + if (!result.first_run) result.changes = diffSettings(old.entries, p.settings.entries); + + const std::string xml_name = fs::path(p.xml_path).filename().string(); + std::ofstream out(p.txt_path, std::ios::binary | std::ios::trunc); + if (!out) + throw std::runtime_error("camera profile '" + name + "': cannot write the settings " + "record " + p.txt_path); + out << renderSettingsRecord(p.settings, xml_name, label); + if (!out) + throw std::runtime_error("camera profile '" + name + "': failed writing " + p.txt_path); + + return p; +} + +std::map loadCameraProfiles( + const std::string& dir, std::map& results) { + results.clear(); + + std::error_code ec; + if (!fs::is_directory(dir, ec)) + throw std::runtime_error("camera profile directory not found: " + dir); + + // Sorted so startup logs and the UI list profiles in a stable order. + std::vector names; + for (const auto& entry : fs::directory_iterator(dir, ec)) + if (entry.is_regular_file() && entry.path().extension() == ".xml") + names.push_back(entry.path().stem().string()); + std::sort(names.begin(), names.end()); + + std::map out; + for (const auto& n : names) { + ProfileLoadResult r; + out.emplace(n, loadCameraProfile(dir, n, r)); + if (r.regenerated) results.emplace(n, std::move(r)); + } + if (out.empty()) + throw std::runtime_error("no camera profiles (*.xml) in " + dir + + " (export them from the VimbaX Viewer)"); + return out; +} + +} // namespace fgc diff --git a/src/core/CameraSettingsXml.cpp b/src/core/CameraSettingsXml.cpp new file mode 100644 index 0000000..45f7e4b --- /dev/null +++ b/src/core/CameraSettingsXml.cpp @@ -0,0 +1,219 @@ +#include "fgc/CameraSettingsXml.h" + +#include +#include +#include +#include +#include +#include + +namespace fgc { + +namespace { + +// The input is machine-generated by the VimbaX Viewer and has a fixed, flat +// shape: elements, some nested in +// . A targeted scanner is enough for that +// and keeps the project free of an XML dependency - but it validates what it +// finds and throws rather than guessing, because a silently mis-parsed settings +// file would be worse than a missing one. + +// Value of `attr` in `tag`, or "" when absent. +std::string attrValue(const std::string& tag, const std::string& attr) { + const std::string key = attr + "=\""; + const auto pos = tag.find(key); + if (pos == std::string::npos) return ""; + const auto start = pos + key.size(); + const auto end = tag.find('"', start); + if (end == std::string::npos) return ""; + return tag.substr(start, end - start); +} + +std::string slurp(const std::string& path) { + std::ifstream in(path, std::ios::binary); + if (!in) throw std::runtime_error("cannot read camera settings XML: " + path); + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); +} + +std::string nowIso8601() { + const std::time_t t = std::time(nullptr); + std::tm tm{}; + localtime_r(&t, &tm); + char buf[32]; + std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", &tm); + return buf; +} + +} // namespace + +CameraSettings parseCameraSettingsXml(const std::string& path) { + const std::string xml = slurp(path); + + CameraSettings out; + const auto info = xml.find("', info); + const std::string tag = xml.substr(info, close - info); + out.model = attrValue(tag, "Model"); + out.device_id = attrValue(tag, "Id"); + } + + // Only : and the stream sections describe the + // host's transport layer, which is per-machine and must not be replayed. + const auto begin = xml.find(""); + if (begin == std::string::npos) + throw std::runtime_error("not a VimbaX settings export (no ): " + path); + const auto end = xml.find("", begin); + if (end == std::string::npos) + throw std::runtime_error("malformed VimbaX settings export (unterminated " + "): " + path); + const std::string body = xml.substr(begin, end - begin); + + // Walk the block in order, tracking the enclosing SelectorGroup so a repeated + // feature name (BalanceRatio for Red and Blue, say) stays distinguishable. + std::string selector; + size_t i = 0; + while (i < body.size()) { + const auto lt = body.find('<', i); + if (lt == std::string::npos) break; + const auto gt = body.find('>', lt); + if (gt == std::string::npos) break; + const std::string tag = body.substr(lt, gt - lt + 1); + i = gt + 1; + + if (tag.rfind(" diffSettings(const std::vector& before, + const std::vector& after) { + // Key by selector + name + occurrence index. A real Viewer export repeats the + // same SelectorGroup name AND value (FileOperationSelector=Open appears once + // per FileSelector group), so selector+name alone is not unique - keying on it + // collapsed the duplicates and reported every one after the first as "new", + // burying a one-line change under hundreds of phantom ones. + auto keyed = [](const std::vector& v) { + std::map m; + std::map seen; + for (const auto& e : v) { + const std::string base = e.selector + "|" + e.name; + m[base + "|" + std::to_string(seen[base]++)] = &e; + } + return m; + }; + auto label = [](const SettingEntry& e) { + return e.selector.empty() ? e.name : e.name + " [" + e.selector + "]"; + }; + + auto old_by_key = keyed(before); + auto new_by_key = keyed(after); + + std::vector out; + for (const auto& [k, e] : new_by_key) { + auto it = old_by_key.find(k); + if (it == old_by_key.end()) { + out.push_back("+ " + label(*e) + " " + e->value + " (new)"); + } else if (it->second->value != e->value) { + out.push_back(label(*e) + " " + it->second->value + " -> " + e->value); + } + } + for (const auto& [k, e] : old_by_key) + if (new_by_key.find(k) == new_by_key.end()) + out.push_back("- " + label(*e) + " (removed)"); + return out; +} + +} // namespace fgc diff --git a/src/core/Config.cpp b/src/core/Config.cpp index adb50bd..5dec1c3 100644 --- a/src/core/Config.cpp +++ b/src/core/Config.cpp @@ -108,6 +108,31 @@ int iniHandler(void* user, const char* section, const char* name, const char* va } // namespace +CameraProfileTiming CameraSpec::timingFor(const std::string& profile_name) const { + auto it = timing.find(profile_name); + return it != timing.end() ? it->second : CameraProfileTiming{}; +} + +std::vector CameraConfig::active() const { + std::vector out; + for (const auto& c : cameras) + if (c.enabled) out.push_back(&c); + return out; +} + +int CameraConfig::maxSettleWaitMs() const { + int worst = 0; + for (const auto* c : active()) worst = std::max(worst, c->timingFor(c->profile).settle_wait_ms); + return worst > 0 ? worst : CameraProfileTiming{}.settle_wait_ms; +} + +int CameraConfig::maxFrameTimeoutMs() const { + int worst = 0; + for (const auto* c : active()) + worst = std::max(worst, c->timingFor(c->profile).frame_timeout_ms); + return worst > 0 ? worst : CameraProfileTiming{}.frame_timeout_ms; +} + std::string NetworkConfig::brokerUri() const { // A scheme means the operator wrote the whole URI: hands off. Appending a port // could corrupt one that already has it, or a ws:// path. @@ -192,25 +217,81 @@ AppConfig ConfigLoader::fromMap(const std::map& kv) { cfg.serial.device = get(kv, "Serial.device", cfg.serial.device); cfg.serial.baud = static_cast(getInt(kv, "Serial.baud", cfg.serial.baud)); - cfg.camera.ids.clear(); - for (const char* key : {"Camera.id_Cam1", "Camera.id_Cam2", "Camera.id_Cam3", "Camera.id_Cam4"}) { - std::string id = get(kv, key); - if (!id.empty()) cfg.camera.ids.push_back(id); + // ---- [Camera] shared defaults, then one [Camera.] block per camera ---- + 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); + + // Cameras are discovered from the key names rather than a list, so there is + // no registry to keep in sync: "Camera.rgb.id" declares a camera called rgb. + // A third dotted segment ("Camera.rgb.standard.settle_wait_ms") is per-profile + // timing, so only two-segment suffixes name a camera. + std::vector names; // in first-seen order + for (const auto& [key, unused] : kv) { + (void)unused; + if (key.rfind("Camera.", 0) != 0) continue; + const std::string rest = key.substr(7); + const auto dot = rest.find('.'); + if (dot == std::string::npos) continue; // [Camera] itself + const std::string name = rest.substr(0, dot); + if (rest.find('.', dot + 1) != std::string::npos) { // per-profile timing + if (std::find(names.begin(), names.end(), name) == names.end()) names.push_back(name); + continue; + } + if (std::find(names.begin(), names.end(), name) == names.end()) names.push_back(name); } - // On-camera imaging + encoding. See CameraConfig for why the defaults are conservative. - 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.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) - throw std::runtime_error("Camera.jxl_effort must be in 1..9"); - if (cfg.camera.rotation_deg != 0 && cfg.camera.rotation_deg != 90 && - cfg.camera.rotation_deg != 180 && cfg.camera.rotation_deg != 270) - throw std::runtime_error("Camera.rotation_deg must be 0, 90, 180 or 270"); + cfg.camera.cameras.clear(); + for (const auto& name : names) { + const std::string pre = "Camera." + name + "."; + CameraSpec c; + c.name = name; + c.enabled = getBool(kv, pre + "enabled", true); + c.driver = get(kv, pre + "driver", c.driver); + c.id = get(kv, pre + "id"); + c.label = get(kv, pre + "label", name); + c.profile_dir = get(kv, pre + "profile_dir"); + c.profile = get(kv, pre + "profile", c.profile); + c.rotation_deg = getInt(kv, pre + "rotation_deg", c.rotation_deg); + c.jxl_distance = getDouble(kv, pre + "jxl_distance", cfg.camera.jxl_distance); + c.jxl_effort = getInt(kv, pre + "jxl_effort", cfg.camera.jxl_effort); + + // Per-profile timing: Camera...settle_wait_ms + for (const auto& [key, val] : kv) { + (void)val; + if (key.rfind(pre, 0) != 0) continue; + const std::string rest = key.substr(pre.size()); + const auto dot = rest.find('.'); + if (dot == std::string::npos) continue; + const std::string profile = rest.substr(0, dot); + const std::string field = rest.substr(dot + 1); + CameraProfileTiming& t = c.timing[profile]; + if (field == "settle_wait_ms") t.settle_wait_ms = getInt(kv, key, t.settle_wait_ms); + else if (field == "frame_timeout_ms") t.frame_timeout_ms = getInt(kv, key, t.frame_timeout_ms); + else throw std::runtime_error("unknown key '" + key + "' (expected " + pre + + profile + ".settle_wait_ms or .frame_timeout_ms)"); + } + + if (c.driver != "vimba") + throw std::runtime_error("Camera." + name + ".driver must be 'vimba' (got '" + + c.driver + "'); no other backend is implemented yet"); + if (c.enabled && c.id.empty()) + throw std::runtime_error("Camera." + name + ".id is required (USB DEV_ id or GigE IP)"); + if (c.profile_dir.empty()) + throw std::runtime_error("Camera." + name + ".profile_dir is required"); + if (c.jxl_distance < 0.0) + throw std::runtime_error("Camera." + name + ".jxl_distance must be >= 0 (0 = lossless)"); + if (c.jxl_effort < 1 || c.jxl_effort > 9) + throw std::runtime_error("Camera." + name + ".jxl_effort must be in 1..9"); + if (c.rotation_deg != 0 && c.rotation_deg != 90 && c.rotation_deg != 180 && + c.rotation_deg != 270) + throw std::runtime_error("Camera." + name + ".rotation_deg must be 0, 90, 180 or 270"); + for (const auto& [pname, t] : c.timing) { + if (t.settle_wait_ms < 0 || t.frame_timeout_ms < 0) + throw std::runtime_error("Camera." + name + "." + pname + + " timings must be >= 0"); + } + cfg.camera.cameras.push_back(std::move(c)); + } std::string out = get(kv, "Paths.output_dir"); cfg.paths.output_dir = out.empty() ? paths::defaultOutputDir() : paths::expandUser(out); @@ -317,11 +398,11 @@ 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(); + // Same for each camera's profile directory. + for (auto& c : cfg.camera.cameras) { + std::filesystem::path pd(paths::expandUser(c.profile_dir)); + if (pd.is_relative()) pd = std::filesystem::path(path).parent_path() / pd; + c.profile_dir = pd.lexically_normal().string(); } return cfg; } diff --git a/src/core/Hash.cpp b/src/core/Hash.cpp new file mode 100644 index 0000000..899127b --- /dev/null +++ b/src/core/Hash.cpp @@ -0,0 +1,40 @@ +#include "fgc/Hash.h" + +#include +#include +#include +#include + +namespace fgc { + +namespace { +constexpr uint64_t kOffsetBasis = 0xcbf29ce484222325ULL; +constexpr uint64_t kPrime = 0x00000100000001b3ULL; +} // namespace + +uint64_t fnv1a64(const std::string& data) { + uint64_t h = kOffsetBasis; + for (unsigned char c : data) { + h ^= c; + h *= kPrime; + } + return h; +} + +std::string hashLabel(uint64_t digest) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "fnv1a64:%016llx", static_cast(digest)); + return buf; +} + +uint64_t fnv1a64File(const std::string& path) { + // Binary: the digest must not depend on how the platform treats line endings, + // or a file copied between machines would look changed when it is not. + std::ifstream in(path, std::ios::binary); + if (!in) throw std::runtime_error("cannot read file for hashing: " + path); + std::ostringstream ss; + ss << in.rdbuf(); + return fnv1a64(ss.str()); +} + +} // namespace fgc diff --git a/src/core/HelpText.cpp b/src/core/HelpText.cpp index eaa492e..5f6a702 100644 --- a/src/core/HelpText.cpp +++ b/src/core/HelpText.cpp @@ -102,11 +102,12 @@ const std::vector& helpCatalog() { {"start", "Begin the capture scan.", {}}, {"stop", "Halt the capture scan.", {}}, {"set fps ", "Set capture rate in images/second.", {}}, - {"set camera mode ", - "Switch the camera mode (rate, exposure limits, imaging settings).", { - "Modes are files in [Camera] mode_dir, one .ini each; the name is the", - "filename stem.", - "Example: set camera mode night"}}, + {"set camera profile [] ", + "Switch a camera's profile (rate, exposure, all imaging settings).", { + "A profile is a settings XML exported from the VimbaX Viewer, in that", + "camera's [Camera.] profile_dir; the name is the filename stem. The", + "camera may be omitted when only one is configured.", + "Example: set camera profile night / set camera profile rgb night"}}, {"set camera jxlq ", "Set JPEG-XL distance (lower = higher quality).", {}}, {"set camera jxle ", "Set JPEG-XL encode effort.", {}}, {"set camera display <0|1>", "Toggle the local display window.", {}}, diff --git a/src/core/TelemetryParser.cpp b/src/core/TelemetryParser.cpp index 16cb2c9..0a0893f 100644 --- a/src/core/TelemetryParser.cpp +++ b/src/core/TelemetryParser.cpp @@ -53,6 +53,7 @@ std::optional parseAxisSegment(const std::string& seg) { switch (c) { case 'S': a.standstill = true; break; case 's': a.stall = true; break; + case 'd': a.deviation = true; break; case 'o': case 'O': a.overtemp = true; break; case 'L': a.endstop_l = true; break; case 'R': a.endstop_r = true; break; diff --git a/src/ui/TuiUi.cpp b/src/ui/TuiUi.cpp index 4d9cd69..3e1aa0f 100644 --- a/src/ui/TuiUi.cpp +++ b/src/ui/TuiUi.cpp @@ -247,12 +247,12 @@ Element axisRow(const AxisView& a) { text(" x=" + std::to_string(a.xactual)) | dim, text(" enc=" + std::to_string(a.xenc)) | dim}), hbox({text(" "), - badge("STILL", a.standstill, Color::GrayLight), + badge("STILL", a.standstill, Color::Cyan), badge("MOVE", a.moving, Color::Cyan), - badge("STALL", a.stall, Color::Red), + badge("STALL", a.deviation, Color::Red), badge("OT", a.overtemp, Color::Red), - badge("L", a.endstop_l, Color::Yellow), - badge("R", a.endstop_r, Color::Yellow)}), + badge("L", a.endstop_l, Color::Cyan), + badge("R", a.endstop_r, Color::Cyan)}), }); } @@ -305,8 +305,17 @@ Element cameraPanel(const CaptureView& c) { if (!c.scan_error.empty()) rows.push_back(labelRow("scan", hbox({text(" GRID LOAD FAILED ") | color(Color::Red) | bold, text(" c for details") | dim}))); - rows.push_back(labelRow("mode", text(c.config.mode.empty() ? "\xE2\x80\x94" : c.config.mode) | - color(Color::Cyan))); + { + // One line per camera: "