Save raw 12-bit camera frames as 12-bit colour images, keep a waypoint's picture once the image has settled, and hold the scan when saving falls behind

This commit is contained in:
pgdalmeida 2026-10-05 10:14:53 +02:00
parent d5b037a241
commit cfd8ba9978
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
38 changed files with 5473 additions and 139 deletions

View File

@ -93,6 +93,8 @@ add_library(fgc_core STATIC
src/core/Hash.cpp
src/core/ChannelHistory.cpp
src/core/ChannelTopics.cpp
src/core/RawImage.cpp
src/core/SettleRule.cpp
src/sensors/I2cBus.cpp
src/sensors/Sht41Protocol.cpp
src/sensors/McuEnvSensor.cpp
@ -109,6 +111,18 @@ target_link_libraries(fgc_core PUBLIC Threads::Threads)
# PUBLIC so the application and the tests both see the same build identity.
target_compile_definitions(fgc_core PUBLIC FGC_GIT_REV="${FGC_GIT_REV}")
# ---------------------------------------------------------------------------
# fgc_imaging: turning camera frames into saved images (de-Bayer, JPEG XL). Its
# own library so the image tests can link OpenCV and libjxl without the rest of
# the application; fgc_core stays free of both.
# ---------------------------------------------------------------------------
add_library(fgc_imaging STATIC
src/camera/JpegXlEncoder.cpp
src/camera/ImagePipeline.cpp
)
target_include_directories(fgc_imaging PUBLIC ${OpenCV_INCLUDE_DIRS})
target_link_libraries(fgc_imaging PUBLIC fgc_core ${OpenCV_LIBS} PkgConfig::JXL)
# ---------------------------------------------------------------------------
# Application executable. Camera image pipeline + serial controller always
# build (OpenCV/libjxl/Boost.Asio are available everywhere); MQTT and Vimba
@ -117,8 +131,6 @@ target_compile_definitions(fgc_core PUBLIC FGC_GIT_REV="${FGC_GIT_REV}")
set(FGC_SOURCES
main.cpp
src/core/Application.cpp
src/camera/JpegXlEncoder.cpp
src/camera/ImagePipeline.cpp
src/serial/SerialMotorController.cpp
src/serial/MtiImuSource.cpp
src/sensors/Sht41EnvSensor.cpp
@ -135,6 +147,7 @@ target_include_directories(fire_gimbal_control PRIVATE ${CMAKE_SOURCE_DIR})
target_link_libraries(fire_gimbal_control PRIVATE
fgc_core
fgc_imaging
${OpenCV_LIBS}
Boost::headers
Boost::program_options

View File

@ -40,28 +40,33 @@ 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.
`config.ini` ships referencing `standard` and `night`. Both are **raw capture**
(docs/raw12-pipeline-measurements.md): full resolution, binning 1, packed 12-bit
Bayer, converted to colour on the tower and saved as 12-bit JPEG XL.
`scripts/export_camera_profile.py` builds them on the tower; these are its recipe.
| Setting | `standard` | `night` |
|---|---|---|
| `AcquisitionFrameRate` | 10 | 1 |
| `SensorBitDepth` | Bpp8 | Bpp12 |
| `ExposureAutoMin` | 200 | 1000 |
| `ExposureAutoMax` | 20000 | 500000 |
| `BinningHorizontal` / `Vertical` | 1 | 1 |
| `Width` / `Height` | sensor maximum | sensor maximum |
| `PixelFormat` | `BayerRG12p` | `BayerRG12p` |
| `DeviceLinkThroughputLimit` | 250000000 | 250000000 |
| `AcquisitionFrameRate` | camera maximum (9.86 on the 1800 U-1620c) | camera maximum (exposure-limited at night) |
| `ChunkModeActive` + `ChunkEnable` | ExposureTime, Gain, BalanceRatioRed/Blue, Timestamp | same |
| `BalanceWhiteAuto` | Off (fixed ratios) | Off |
| `ExposureAutoMin` / `Max` | 200 / 20000 µs | 1000 / 500000 µs |
| `GainAutoMax` | 24 | 48 |
| `IntensityAutoPrecedence` | Minimizeexposuretime | Minimizenoise |
| `DeviceIndicatorMode` | Active | Off |
| `DeviceIndicatorLuminance` | 10 | 0 |
| `IntensityAutoPrecedence` | MinimizeNoise | MinimizeNoise |
| `IntensityControllerRate` | 100 | 100 |
| `DeviceIndicatorMode` / `Luminance` | Active / 10 | Inactive / 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 chunk data is what `settle_rule = brightness` reads (each item has to be
enabled before `ChunkModeActive`, which then locks them). A profile left on
`RGB8` still works: the program saves it as before.
The full previous definitions are in git history at
the hand-written `config/camera_modes/rgb/{standard,night}.ini`, removed in the
same change that made the XML the only thing applied.
Earlier profiles used 2×2 binning at RGB8. On the 1620c that gave **no** extra
frame rate (the camera budgets the link against the unbinned frame), which is one
reason they were replaced. Their values are in git history.
`settle_wait_ms` / `frame_timeout_ms` are *not* camera settings and are not in
`settle_wait_ms` / `frame_timeout_ms` / `settle_rule` are *not* camera settings and are not in
the XML — they live in `config.ini` under `[Camera.rgb.<profile>]`.

View File

@ -50,6 +50,10 @@ mqtt_pw = CHANGE_ME
; JPEG XL encoding. distance: 0 = lossless, ~0.8 = near-lossless; effort 1..9.
jxl_distance = 0.8
jxl_effort = 4
; Raw profiles (PixelFormat BayerRG8 / BayerRG12p in the profile XML): the tower
; converts the sensor mosaic to colour itself. edge_aware (sharper; ~30 ms for
; 16 MP) or bilinear. A 12-bit raw frame is saved as a 12-bit file.
demosaic = edge_aware
; --- One [Camera.<name>] block per camera -----------------------------------
; Nothing is implied by position: the block names the camera, `label` names its
@ -71,13 +75,20 @@ profile = standard ; <profile_dir>/standard.xml
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.
; them. After the gimbal reports standstill it holds still settle_wait_ms, then
; keeps a frame according to settle_rule:
; fixed the newest frame, straight away
; brightness the first frame once brightness is in the camera's target band
; and steady for 3 frames (right 21 of 24 times in the tower tests,
; docs/raw12-pipeline-measurements.md); settle_wait_ms is then just
; the mechanical minimum
; frame_timeout_ms bounds the wait after settle_wait_ms; past it the newest frame
; is kept anyway (or the waypoint skipped if the camera delivers nothing).
; With several cameras the scan uses the LONGEST wait across them.
[Camera.rgb.standard]
settle_wait_ms = 1000
frame_timeout_ms = 2000
settle_wait_ms = 500
frame_timeout_ms = 7000
settle_rule = brightness
[Camera.rgb.night]
settle_wait_ms = 2500

View File

@ -58,6 +58,15 @@ cameras = rgb
; How much bearing one picture covers, in degrees. Pan mode ('mode pan') cuts
; its span into as many pictures as this takes. 6 until the lens is measured.
pan_step_deg = 6
; Image saving: how this computer shares its 4 cores between saved frames.
; Measured with a 16 MP 12-bit frame (docs/raw12-pipeline-measurements.md): the
; IOTA (N150) keeps up with one waypoint every 0.7 s with 1 worker; the 3 Delta
; (N5105) needs 2 workers for that. jxl_threads is libjxl's threads per frame;
; 0 or absent = 4 / save_workers. save_queue_max is how many frames may wait
; or be saving before the scan holds at its waypoint (each raw frame ~24 MB).
save_workers = 1
;jxl_threads = 0
save_queue_max = 4
;
; What happens when this does not match the rig:
; declared but absent -> warned about by name, and with [Features]

View File

@ -132,7 +132,14 @@ At each waypoint the scheduler therefore:
1. moves, and waits for both axes to report standstill at the target;
2. waits a further `settle_wait_ms` (1 s in `standard`, 2.5 s in `night`);
3. keeps the newest complete frame.
3. keeps the newest complete frame - or, with `settle_rule = brightness`, the first
frame once brightness is in the camera's target band and steady for 3 frames
([configuration.md](configuration.md#which-frame-a-waypoint-keeps-settle_rule)).
Saving runs in background workers and never blocks this loop; if more than
`save_queue_max` frames are waiting, the scan holds at the waypoint until there is
room. Raw profiles (`BayerRG8`, `BayerRG12p`) are converted to colour on the tower
and saved at their own bit depth ([raw12-pipeline-measurements.md](raw12-pipeline-measurements.md)).
The wait is the whole mechanism. It guarantees the kept frame was exposed with the gimbal **at rest** (no
slew blur) and after the auto-exposure has seen this waypoint's light for several frames. It also sets the

View File

@ -44,9 +44,14 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| `Camera.<name>` | `enabled` | bool | `true` | A disabled camera is not opened and does not affect the scan timing |
| `Camera.<name>` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to that camera's saved images |
| `Camera.<name>.<profile>` | `settle_wait_ms` | int ≥ 0 | `1000` | Hold after standstill before keeping a frame |
| `Camera.<name>.<profile>` | `frame_timeout_ms` | int ≥ 0 | `2000` | Abandon the waypoint after this |
| `Camera.<name>.<profile>` | `frame_timeout_ms` | int ≥ 0 | `2000` | Longest wait after `settle_wait_ms`; then keep the newest frame, or skip the waypoint if none |
| `Camera.<name>.<profile>` | `settle_rule` | `fixed` \| `brightness` | `fixed` | `fixed`: keep the newest frame once `settle_wait_ms` has passed. `brightness`: keep the first frame whose brightness is in the camera's target band and steady for 3 frames ([keep rule](#which-frame-a-waypoint-keeps-settle_rule)) |
| `Camera` | `jxl_distance` / `jxl_effort` | double ≥ 0 / int 1..9 | `0.8` / `4` | Encoder defaults inherited by every camera block |
| `Camera.<name>` | `jxl_distance` / `jxl_effort` | double ≥ 0 / int 1..9 | the `[Camera]` value | Per-camera override of the encoder settings |
| `Camera.<name>` | `jxl_distance` / `jxl_effort` | double ≥ 0 / int 1..9 | the `[Camera]` value | Per-camera override of the encoder settings. Distance 0 is truly lossless |
| `Camera` / `Camera.<name>` | `demosaic` | `edge_aware` \| `bilinear` | `edge_aware` | How raw profiles (`BayerRG8`, `BayerRG12p`) are converted to colour on the tower |
| `Hardware` (hardware.ini) | `save_workers` | int 1..4 | `1` | Frames saved at once. 1 on the IOTA, 2 on the 3 Delta ([measurements](raw12-pipeline-measurements.md)) |
| `Hardware` (hardware.ini) | `jxl_threads` | int 0..8 | `0` = 4 / `save_workers` | libjxl threads per saved frame |
| `Hardware` (hardware.ini) | `save_queue_max` | int 1..32 | `4` | Frames waiting or being saved before the scan holds at its waypoint (a raw 16 MP frame is ~24 MB) |
| `Paths` | `output_dir` | string | `$XDG_DATA_HOME/fire_gimbal_control/images` | Image output dir; supports `~`/`$ENV` |
| `Features` | `enable_mqtt` | bool | `true` | Use MQTT (vs null channel) |
| `Features` | `enable_camera` | bool | `true` | (reserved) |
@ -432,6 +437,39 @@ 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.
#### Raw profiles
A profile whose XML sets `PixelFormat = BayerRG12p` (or `BayerRG8`) sends the
camera's raw sensor mosaic. The tower converts it to colour (`[Camera] demosaic`)
and saves it at the frame's own bit depth: a 12-bit raw frame becomes a 12-bit
JPEG XL file. `RGB8` profiles are saved exactly as before. At the 250 MB/s link
cap the 1800 U-1620c delivers 9.86 fps in `BayerRG12p` against 4.95 in RGB8, so
auto-exposure settles in about half the time; the tower-side cost is the same as
saving RGB8 ([measurements](raw12-pipeline-measurements.md)).
`scripts/export_camera_profile.py` builds such profiles; it also turns on the
per-frame data (chunk mode) the keep rule reads.
#### Which frame a waypoint keeps: `settle_rule`
With `settle_rule = brightness`, after `settle_wait_ms` (now the mechanical
minimum) the scan watches every frame the camera delivers and keeps the first one
once the last three are all inside the camera's own auto-exposure target band
(`IntensityControllerTarget ± IntensityControllerTolerance`, read from the camera)
and their brightness changed by at most 2% from frame to frame. A steady frame
outside the band also counts when exposure and gain sit at the limits that would
move it (waiting cannot help). Past `frame_timeout_ms` the newest frame is kept
anyway, with a warning. On the 24 recorded exposure steps this picked a good frame
21 times; "exposure and gain unchanged" never did, because auto-exposure pauses
mid-way. A camera that reports no frame data falls back to the fixed wait.
#### When saving falls behind
Saving runs in background workers (`[Hardware] save_workers`, `jxl_threads`). If
more than `save_queue_max` frames are waiting or being saved, the scan **holds at
its waypoint** until there is room (logged once), so no picture is lost and memory
stays bounded. A requested snapshot is never held. Measured: the IOTA keeps up
with a frame every 0.7 s on one worker; the 3 Delta needs two.
### `[Env]` — SHT41 ambient temperature/humidity sensor
Enable with `[Features] enable_env = true` and point `[Env] i2c_device` at the LattePanda's **own

View File

@ -30,9 +30,9 @@ doctest unit-test suite (`ctest`).
| 13 | `[Motor]` degrees↔counts calibration | The `config.example.ini` values are **placeholders**. Home, then run `gimbal heading setup` (yaw, needs no IMU) and `gimbal pitch setup` (pitch, needs the IMU) 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 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. **2026-09-24:** tower 121 has still never imaged a scene - a frame grabbed directly and the program's own saved images (2026-09-21) are a smooth grey gradient with no edges, i.e. the lens is covered or grossly out of focus. Tower 111's images from the same afternoon show nothing sharp anywhere either (soft blobs, no edges) - defocus, a cover or a fogged window; unexplained. `test camera` (below) proves the camera and its data path work in both cases; it cannot, by design, judge the picture. **Stream stuck, 2026-09-24:** from 16:10 to 16:19 tower 111's camera delivered no frames at all - not to the program, not to a fresh Vimba session, not at a fixed 10 ms exposure - and it survived program restarts and a USB re-authorisation. The camera's own `DeviceReset` command cleared it. Production captured nothing for those 9 minutes and logged only `no frame yet (stream warming up)`: a session that has NEVER delivered is not restarted by `trigger()`, by design, so a stream stuck from its start stays stuck. It began just after a `test camera` run by an early version that stopped and restarted the stream several times in a few seconds (to write `BlackLevel`, which the camera refuses while streaming); whether that caused it or met 111's known USB stall is not established. `test camera` itself reported it correctly. **Answered the same day:** a stream that never delivers is now restarted once the wait exceeds what its exposure can explain (at least 30 s), and after two fruitless restarts the camera is reset (DeviceReset) and reopened on its profile - both by the program itself (`CameraStall.h`), and on demand with the console command `camera reset` (verified on tower 121: back in ~14 s, frames straight after). `test camera` no longer stops the stream at all: it changes only exposure and gain, which are writable live, and puts them back live. With that, 111 ran it inside the capturing program, passed, and kept capturing. **2026-09-30:** tower 121's camera is indoors, facing a window, still badly out of focus, gain at its 24 dB maximum ([camera-capture-measurements.md](camera-capture-measurements.md)). |
| 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. **Seen in real data 2026-09-24:** of eight consecutive images tower 111 kept at 8 deg/s, one was 98.6 % clipped white, one 57 % black and two about half clipped - frames kept before the continuous auto-exposure had caught up with the new view. **Measured 2026-09-30 (tower 121, still camera, forced ×16 brightness steps):** auto-exposure took 1.4–1.8 s to come within ±10% at 4.95 fps, so 1 s is too short for a large step; from a dark step it first overshoots to its maximum exposure, 3.4–4.1× too bright. Exposure and gain holding still for a few frames does not mean it has finished ([camera-capture-measurements.md](camera-capture-measurements.md)). |
| 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. **Seen in real data 2026-09-24:** of eight consecutive images tower 111 kept at 8 deg/s, one was 98.6 % clipped white, one 57 % black and two about half clipped - frames kept before the continuous auto-exposure had caught up with the new view. **Measured 2026-09-30 (tower 121, still camera, forced ×16 brightness steps):** auto-exposure took 1.4–1.8 s to come within ±10% at 4.95 fps, so 1 s is too short for a large step; from a dark step it first overshoots to its maximum exposure, 3.4–4.1× too bright. Exposure and gain holding still for a few frames does not mean it has finished ([camera-capture-measurements.md](camera-capture-measurements.md)). **2026-10-05:** `settle_rule = brightness` keeps the frame once brightness is in the camera's band and steady (21 of 24 recorded steps right), and raw profiles double the frame rate it works with; to be confirmed with real slews ([raw12-pipeline-measurements.md](raw12-pipeline-measurements.md)). |
| 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. **Solved on tower 121 by the setup kit** (`hardware/lattepanda-iota/preconditions/`): a systemd-tmpfiles drop-in for usbfs and two udev rules, all installed by `install.sh --preconditions`. Verified 2026-09-23 — `usbfs_memory_mb` reads 1000 after **6 days of uptime**, so it survived the boot, and both rules are in `/etc/udev/rules.d/`; and again across a deliberate reboot the same day, when `install-tower --verify` read `usbfs_memory_mb` 1000 and the camera port's `usb3_lpm_permit` 0 (a check `test hardware camera` now makes too, once `[Test] usb3_lpm_path` is set - the installer sets it). Note it is **tmpfiles, not the kernel cmdline** the original entry proposed. **On the Delta fleet**: tower 111 read 16 and `u1_u2` on 2026-09-23, and the Delta kit (`hardware/lattepanda-3-delta/`, the same two files) was applied the same day - 1000 and `0` afterwards, confirmed by `test hardware camera`. Confirmed across a reboot on 2026-09-24 (`install-tower --verify`). **Solved on both boards**; open only for Delta towers the kit has not reached. **2026-09-30:** with the kit applied, tower 121 streamed full resolution in every format at 250 and 450 MB/s for 60 s with nothing lost; 2 incomplete frames in 1,574 at 450 MB/s while encoding. **2026-10-01:** tower 111 (Delta kit applied) likewise, at 200, 250 and 450 MB/s, also while encoding, with nothing lost ([camera-capture-measurements.md](camera-capture-measurements.md)). |
| 20 | **`jxl_distance = 0` is not lossless** | `JpegXlEncoder::encodeToFile` always sets `uses_original_profile = false`; for distance 0 libjxl prints `Set uses_original_profile=true for lossless encoding`, still reports success, and encodes through its lossy colour space. Measured 2026-09-30: decoded pixels differ by up to 1 (raw mosaic) or 2 (RGB8) levels in 1.5% / 7.3% of values ([camera-capture-measurements.md](camera-capture-measurements.md)). Fix: set `uses_original_profile = true` when distance is 0. |
| 20 | **`jxl_distance = 0` is not lossless** | `JpegXlEncoder::encodeToFile` always sets `uses_original_profile = false`; for distance 0 libjxl prints `Set uses_original_profile=true for lossless encoding`, still reports success, and encodes through its lossy colour space. Measured 2026-09-30: decoded pixels differ by up to 1 (raw mosaic) or 2 (RGB8) levels in 1.5% / 7.3% of values ([camera-capture-measurements.md](camera-capture-measurements.md)). **Fixed 2026-10-05:** the encoder sets `uses_original_profile` for distance 0, and `tests/test_jpegxl.cpp` checks 8-bit and 12-bit lossless decode exactly; 8-bit lossy output is byte-identical to before (72 of 72 compared). |
## Camera acquisition (USB3) — root cause + approach

View File

@ -1,7 +1,32 @@
# Implementation plan — raw capture, colour on the tower, 12-bit JPEG XL
Based on [raw12-pipeline-measurements.md](raw12-pipeline-measurements.md) (towers 121 and 111,
2026-10-02/04). Not started; decisions marked **[decide]** are the user's.
2026-10-02/04).
## Status (branch `raw12-capture`, 2026-10-05)
Decisions taken: `BayerRG12p` for both profiles; colour JPEG XL only (no raw-planes save); white
balance stays fixed; a full save queue **holds the scan** (no lost waypoints).
| Phase | State |
|---|---|
| 1 Encoder | **done**: 12-bit input, true lossless (#20), threads from config; 8-bit lossy byte-identical to before (72 of 72) |
| 2 Frames | **done**: pixel layout from the camera, chunk data (exposure, gain, white balance), brightness sample per frame. Not done: keeping frames in the SDK buffer instead of copying (optional) |
| 3 Raw core | **done**: `RawImage` (layouts, `unpack12p`, brightness sample) |
| 4 Pipeline | **done**: de-Bayer (edge-aware/bilinear), save at the frame's bit depth, N workers, `canAccept()`; scan holds when full |
| 5 Keep rule | **done** for scan waypoints (`SettleRule`, `settle_rule = brightness`); snapshots and continuous capture keep the fixed wait |
| 6 Config | **done**: `[Camera] demosaic`; `[Hardware] save_workers`, `jxl_threads`, `save_queue_max` (kit writes 1 / 2 per board); per-profile `settle_rule` |
| 7 Profiles | **recipe done** (`scripts/export_camera_profile.py`, dry-run checked on the 1620c); profiles still to be exported on each tower |
| 8 Docs | **done** (configuration, architecture, known issues, profile README). Downstream 12-bit decode still to check |
Tests: 457, of which 36 new, covering the encoder, the pipeline, raw handling, the keep rule (including
a replay of the 24 recorded steps), the scheduler and config. Builds with the
camera SDK on tower 121.
Still to do before switching a tower: export the new profiles on the tower; check the ground
station and detection read 12-bit JPEG XL; a daylight run in auto mode on each tower
(verification below); tune `settle_wait_ms` (500 ms is a guess) and `frame_timeout_ms` (7 s from
the worst measured step).
## Goal

View File

@ -5,6 +5,7 @@
#
# The facts a kit must set before sourcing:
# KIT_BOARD lattepanda-iota | lattepanda-3-delta (matches probe_board)
# KIT_SAVE_WORKERS image-saving workers for this board (1 IOTA, 2 3 Delta)
# KIT_FW_ENV the PlatformIO env for this board's MCU
# KIT_MCU_VID USB vendor of the motor MCU (2e8a RP2040, 2341 Arduino)
# KIT_DEFAULT_AXES what this board's towers are usually built with
@ -392,6 +393,11 @@ imu = $IMU
env = $ENV_SENSOR
cooling = $COOLING
cameras = $CAMERAS
; Image saving on this computer: frames encoded at once (libjxl threads per
; frame default to 4 / save_workers), and frames that may wait before the
; scan holds. Measured for this board in docs/raw12-pipeline-measurements.md.
save_workers = ${KIT_SAVE_WORKERS:-1}
save_queue_max = 4
[Serial]
; The motor controller MCU, by stable id - /dev/ttyACM* moves when the modem

View File

@ -16,6 +16,8 @@ KIT_BOARD=lattepanda-3-delta
KIT_FW_ENV=leonardo
KIT_MCU_VID=2341
KIT_DEFAULT_AXES=yaw,pitch
# Image saving: the N5105 needs two workers x 2 threads to keep up (docs/raw12-pipeline-measurements.md).
KIT_SAVE_WORKERS=2
KIT_IMU_DEVICE=/dev/ttyS0
KIT_IMU_NOTE="On the Delta the MTi is on the RS-232 header: a hardware UART, not a USB node."
KIT_ENV_DEVICE=/dev/i2c-2

View File

@ -18,6 +18,8 @@ KIT_BOARD=lattepanda-iota
KIT_FW_ENV=iota_rp2040
KIT_MCU_VID=2e8a
KIT_DEFAULT_AXES=yaw
# Image saving: the N150 keeps up with one worker x 4 threads (docs/raw12-pipeline-measurements.md).
KIT_SAVE_WORKERS=1
KIT_IMU_DEVICE=/dev/ttyS0
KIT_IMU_NOTE="On the IOTA the only PCH serial on the header is COM1; confirm it enumerates first."
# No PCH-side I2C reaches the header, so an ambient sensor needs a USB-I2C bridge

View File

@ -5,6 +5,7 @@
#include "fgc/IMotorController.h"
#include "fgc/OperatingMode.h"
#include "fgc/ScanGrid.h"
#include "fgc/SettleRule.h"
#include <functional>
#include <string>
@ -67,12 +68,29 @@ public:
double imageRate() const { return image_rate_; } // images per second
// Is there room to save another frame (ImagePipeline::canAccept)? Asked
// before moving to the next waypoint and before keeping a continuous-capture
// frame. While it says no, the scan holds where it is, so a scan that
// outruns saving slows down instead of losing pictures or filling memory.
// A requested snapshot is never held. Unset: always room.
void setSaveGate(std::function<bool()> can_save) { can_save_ = std::move(can_save); }
// True while the scan is held for saving to catch up.
bool holdingForSave() const { return holding_; }
// 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);
int settleWaitMs() const { return settle_wait_ms_; }
// The keep rule for scan waypoints (profile setting settle_rule). Off: keep
// the newest frame once settle_wait_ms has passed, as always. On: after
// settle_wait_ms (now the mechanical minimum), wait until the camera's
// frames say the image has settled (SettleRule), within frame_timeout_ms; a
// camera that reports no frame data falls back to the fixed wait.
void setSettleRule(bool on, const SettleRule::Params& params = {});
bool settleRuleOn() const { return use_rule_; }
// Replace the degrees<->counts maps (e.g. after `gimbal heading fit`) so subsequent
// scan waypoints target with the calibrated conversion. Called on the control
// thread, same as tick(), so no locking is needed.
@ -99,6 +117,8 @@ private:
// budget runs out. `settled_ms` is that request's own settle clock.
enum class Shot { Waiting, Captured, TimedOut };
Shot shootAtRest(const MotorTelemetry& t, long long& settled_ms);
// The save gate, with one log line when a hold starts and one when it ends.
bool roomToSave();
IMotorController& motor_;
ICameraSource& camera_;
@ -106,6 +126,8 @@ private:
ScanGrid& grid_;
std::function<long long()> now_ms_;
std::function<bool()> can_save_;
bool holding_ = false;
double image_rate_;
long long timer_start_ = 0;
@ -115,6 +137,10 @@ private:
std::vector<double> pan_headings_;
size_t pan_index_ = 0;
bool use_rule_ = false; // keep by SettleRule, not after a fixed wait
SettleRule rule_;
bool rule_started_ = false; // this waypoint's rule has its first frame number
uint64_t rule_seq_ = 0; // last frame fed to the rule
bool moving_ = false; // a MOVE has been issued, awaiting settle
long long settled_at_ms_ = 0; // when standstill was first seen (0 = not yet)
int settle_wait_ms_ = 1000;

View File

@ -49,6 +49,13 @@ struct HardwareConfig {
// a span is cut into as many pictures as it takes (OperatingMode.h).
double pan_step_deg = 6.0;
// Saving images - a property of the computer, not of what the tower does.
// Measured (docs/raw12-pipeline-measurements.md): the IOTA keeps up with one
// worker x 4 threads, the 3 Delta needs two x 2.
int save_workers = 1; // frames encoded at once
int jxl_threads = 0; // libjxl threads per frame; 0 = 4 / save_workers
int save_queue_max = 4; // frames waiting or saving before the scan holds
// Was a hardware.ini actually read? False on every tower that has not been
// migrated yet, and the switch that keeps this change invisible on rollout.
bool declared = false;
@ -179,6 +186,11 @@ struct SerialConfig {
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
// How to tell the frame is good to keep, after settle_wait_ms:
// fixed keep the newest frame as soon as settle_wait_ms has passed
// brightness wait until brightness is in the camera's target band and
// steady for 3 frames (SettleRule.h), up to frame_timeout_ms
std::string settle_rule = "fixed";
};
// One camera, from a [Camera.<name>] block. Nothing is implied by position: the
@ -196,6 +208,8 @@ struct CameraSpec {
// Encoding, defaulted from [Camera] and overridable per camera.
double jxl_distance = 0.8;
int jxl_effort = 4;
// De-Bayering for raw profiles (BayerRG8/12p): "edge_aware" or "bilinear".
std::string demosaic = "edge_aware";
std::map<std::string, CameraProfileTiming> timing; // by profile name
@ -208,6 +222,7 @@ struct CameraConfig {
// [Camera] defaults, inherited by each block unless it overrides them.
double jxl_distance = 0.8;
int jxl_effort = 4;
std::string demosaic = "edge_aware";
// Enabled cameras only.
std::vector<const CameraSpec*> active() const;

View File

@ -1,6 +1,8 @@
#pragma once
#include "fgc/CameraProfile.h"
#include "fgc/RawImage.h"
#include "fgc/SettleRule.h"
#include <cstdint>
#include <functional>
@ -11,12 +13,25 @@ namespace fgc {
// One captured frame, owning a copy of its pixel buffer (was image_store_8bit).
struct Frame {
std::vector<uint8_t> data;
std::vector<uint8_t> data; // as delivered: BayerRG12p stays packed
uint32_t width = 0;
uint32_t height = 0;
int channels = 0; // 1 (mono) or 3 (RGB)
int channels = 0; // values per pixel as delivered: 3 (RGB) or 1 (mono, Bayer mosaic)
// How `data` is laid out. Unknown only for a format the program does not
// handle; `channels` then says what it holds, as before layouts existed.
PixelLayout layout = PixelLayout::Unknown;
long long timestamp_ms = 0; // Unix epoch ms
int cam_id = 0; // index into the configured camera list
// What the camera says it exposed this frame with (its per-frame chunk
// data). Negative when the profile does not turn chunk data on.
double exposure_us = -1.0;
double gain_db = -1.0;
double wb_red = -1.0; // BalanceRatio, red against green
double wb_blue = -1.0;
// Sampled from `data` on arrival (see RawImage.h); invalid when the layout
// is Unknown.
BrightnessSample brightness;
};
// Live per-camera identity, sensor telemetry, and acquisition stats, for the
@ -77,6 +92,20 @@ public:
virtual void setFrameCallback(FrameCallback cb) = 0;
// ---- For the keep rule (SettleRule.h) ---------------------------------------
// The most recent frames camera 0 delivered, with their brightness, exposure
// and gain. Sequence numbers count up for the session; framesSince(n) returns
// the frames after n, oldest first (only the last few dozen are kept).
// frameSeq() is the newest sequence number. Default: no frames, and the scan
// keeps its fixed settle wait.
virtual uint64_t frameSeq() { return 0; }
virtual std::vector<FrameStats> framesSince(uint64_t /*after*/) { return {}; }
// A numeric camera feature (IntensityControllerTarget, ExposureAutoMax...).
// False if the camera has no such feature.
virtual bool readFeature(int /*camera_index*/, const std::string& /*name*/, double& /*value*/) {
return false;
}
// Number of cameras this source manages.
virtual int cameraCount() const = 0;

View File

@ -23,9 +23,11 @@ struct Orientation {
// Consumes captured frames and turns them into stored artifacts: rotate by
// Camera.rotation_deg, encode to JPEG XL, write to
// <output_dir>/<label>/<timestamp>.jxl, and publish a CamEvent. Runs a
// background worker so encoding never blocks acquisition. Split out of the old
// VimbaHandler::SaveImage so any camera source (real or mock) can feed it.
// <output_dir>/<label>/<timestamp>.jxl, and publish a CamEvent. Raw frames
// (BayerRG8, BayerRG12p) are de-Bayered first and saved at their own bit depth,
// so a 12-bit frame becomes a 12-bit file. Runs background workers so encoding
// never blocks acquisition. Split out of the old 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
@ -36,12 +38,23 @@ public:
int rotation_deg = 0; // clockwise; 0/90/180/270
double jxl_distance = 0.8; // 0 = lossless
int jxl_effort = 4;
// De-Bayering for raw frames: edge-aware (sharper, ~2x the time, still
// ~30 ms for 16 MP) or bilinear.
bool edge_aware = true;
};
struct Params {
std::string output_dir;
std::vector<CameraOutput> cameras; // by camera index, in open order
std::string tower;
// How saving shares the CPU (hardware.ini): frames encoded at once, and
// libjxl threads per frame. Measured: one worker x 4 threads on the IOTA,
// two x 2 on the 3 Delta (docs/raw12-pipeline-measurements.md).
int workers = 1;
int jxl_threads = 4;
// Frames waiting or being saved beyond which canAccept() says no, so the
// scan holds instead of piling frames up in memory (24 MB raw each).
int queue_max = 4;
};
// orientation: returns the current gimbal yaw+pitch (degrees) for CamEvent.
@ -64,6 +77,14 @@ public:
// different pose than the pixels.
void submit(const Frame& frame);
// Is there room for another frame? False once queue_max frames are waiting
// or being saved. The scheduler asks before moving to the next waypoint, so
// a scan that outruns the save rate pauses rather than losing pictures.
// submit() itself never refuses: a frame already captured is always saved.
bool canAccept() const;
// Frames waiting or being saved right now.
size_t pending() const;
// Last CamEvent published (camera label, heading/pitch, timestamp), for the
// UI's camera panel. Thread-safe; nullopt until the first capture is saved.
std::optional<CamEvent> lastEvent() const;
@ -81,6 +102,8 @@ private:
void run();
void process(const QueuedFrame& queued);
// Encode and write one image; false (logged) on failure.
bool save(const Frame& frame, const CameraOutput& out, const std::string& file);
// 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;
@ -90,10 +113,11 @@ private:
Params params_;
std::queue<QueuedFrame> queue_;
std::mutex mutex_;
mutable std::mutex mutex_;
std::condition_variable cv_;
std::atomic<bool> running_{false};
std::thread worker_;
std::vector<std::thread> workers_;
size_t in_flight_ = 0; // frames a worker is saving (under mutex_)
mutable std::mutex last_event_mutex_;
std::optional<CamEvent> last_event_;

View File

@ -5,16 +5,34 @@
namespace fgc {
// Thin wrapper around libjxl for encoding an 8-bit interleaved image buffer
// to a JPEG XL file. Replaces the header-only JPEG_XL.h class.
// Thin wrapper around libjxl for encoding an interleaved image buffer to a JPEG
// XL file. Replaces the header-only JPEG_XL.h class.
class JpegXlEncoder {
public:
// Encode `img` (width*height*channels bytes, 8-bit, 1 or 3 channels) to
// `path`. `distance` is the JPEG XL frame distance (0 = lossless, higher =
// lossier); `effort` is the libjxl effort level. Returns true on success.
static bool encodeToFile(const std::string& path, const uint8_t* img, int width,
int height, int channels, float distance, int effort,
int threads = 3);
// Encode `img` (1 or 3 interleaved channels) to `path`.
//
// `bits` is the bit depth of the values handed in, and is what the file
// records:
// 8 one byte per value (RGB8, Mono8, BayerRG8);
// 9..16 one native-endian 16-bit word per value holding 0..2^bits-1, e.g.
// 12 for BayerRG12p after unpacking. libjxl must be told the values
// use only `bits` bits, or it reads them as 0..65535 and rescales -
// and a "lossless" file then keeps only the top bits.
//
// `distance` is the JPEG XL frame distance: 0 = mathematically lossless,
// higher = lossier (1.0 is meant to be visually lossless). `effort` is
// libjxl's 1..9. `threads` is libjxl's worker count; 0 encodes on the
// calling thread. Returns false (and logs) on any failure; nothing is left
// at `path` unless the encode succeeded.
static bool encodeToFile(const std::string& path, const void* img, int width, int height,
int channels, int bits, float distance, int effort, int threads);
// 8-bit shorthand, as the program has always called it.
static bool encodeToFile(const std::string& path, const uint8_t* img, int width, int height,
int channels, float distance, int effort, int threads = 3) {
return encodeToFile(path, static_cast<const void*>(img), width, height, channels, 8,
distance, effort, threads);
}
};
} // namespace fgc

56
include/fgc/RawImage.h Normal file
View File

@ -0,0 +1,56 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
namespace fgc {
// How a camera frame's bytes are laid out. The camera profile decides which one
// arrives (PixelFormat in the XML); the program accepts these and refuses others.
//
// Mono8 one byte per pixel, grey
// RGB8 three bytes per pixel, R G B - de-Bayered on the camera
// BayerRG8 one byte per pixel, the raw sensor mosaic:
// R G R G ...
// G B G B ...
// BayerRG12p the same mosaic at 12 bits, packed: two pixels in three bytes
// (GenICam "12p": p0 = b0 | (b1 & 0x0F) << 8,
// p1 = b1 >> 4 | b2 << 4)
//
// OpenCV names Bayer patterns by the second row, so the camera's BayerRG is
// OpenCV's COLOR_BayerBG2* (measured 2026-09-30 against the camera's own RGB8).
enum class PixelLayout { Unknown, Mono8, RGB8, BayerRG8, BayerRG12p };
// The GenICam name ("BayerRG12p"), or "Unknown".
const char* layoutName(PixelLayout layout);
// From a GenICam name; Unknown for anything the program does not handle.
PixelLayout layoutFromName(const std::string& name);
// True for the raw mosaic layouts, which need de-Bayering before saving as colour.
bool layoutIsBayer(PixelLayout layout);
// Bits per value as delivered: 8, or 12 for BayerRG12p. 0 for Unknown.
int layoutBits(PixelLayout layout);
// Bytes one frame of width x height takes in this layout (0 for Unknown).
size_t layoutFrameBytes(PixelLayout layout, uint32_t width, uint32_t height);
// Unpack `pixels` BayerRG12p values from `in` into one 16-bit word each
// (0..4095). `in` holds ceil(pixels * 3 / 2) bytes; an odd last pixel uses the
// low half of its pair, as GenICam specifies.
void unpack12p(const uint8_t* in, uint16_t* out, size_t pixels);
// Brightness of a frame from a sparse sample of its pixels: every `stride`-th
// pixel, so all colour sites of a mosaic are visited. Cheap enough to run on
// every frame the camera delivers (a few thousand samples).
// mean on a 0..255 scale whatever the bit depth (12-bit values / 16)
// clipped share of samples at or near white (>= 250 on that scale)
struct BrightnessSample {
double mean = -1.0;
double clipped = -1.0;
size_t samples = 0;
bool valid() const { return samples > 0; }
};
BrightnessSample sampleBrightness(const uint8_t* frame, size_t bytes, PixelLayout layout,
uint32_t width, uint32_t height, size_t stride = 997);
} // namespace fgc

76
include/fgc/SettleRule.h Normal file
View File

@ -0,0 +1,76 @@
#pragma once
#include <cstdint>
#include <deque>
namespace fgc {
// What the scheduler needs to know about one delivered frame to judge whether
// the camera's auto-exposure has settled: its place in the stream, its
// brightness (sampled from the pixels, 0..255, negative if unknown) and the
// exposure and gain it was taken with (chunk data, negative if unknown).
struct FrameStats {
uint64_t seq = 0;
double brightness = -1.0;
double exposure_us = -1.0;
double gain_db = -1.0;
};
// When has the image settled after the gimbal stops? Measured on 24 forced
// exposure steps (docs/raw12-pipeline-measurements.md, "Which frame to keep"):
//
// "exposure and gain unchanged for 3 frames" right 0 of 24 times
// "brightness in the camera's target band, 2 frames" right 12 of 24
// "in the band AND steady (<= 2% change) for 3 frames" right 21 of 24 <- this
//
// Auto-exposure moves in steps and pauses mid-way, so values that hold still
// prove nothing; and coming down from too bright, brightness enters the band
// ~15% above where it settles, so "in band" alone fires early. The band is the
// camera's own: IntensityControllerTarget +- IntensityControllerTolerance (in
// percent of full scale), 50 +- 5 by default.
//
// A second way to finish: brightness is out of the band but steady, and exposure
// and gain are at the limits that would move it towards the band. Waiting cannot
// help then (a scene too dark or too bright for the camera's limits).
class SettleRule {
public:
struct Params {
double target_pct = 50.0; // IntensityControllerTarget
double tolerance_pct = 5.0; // IntensityControllerTolerance
int frames = 3; // consecutive frames that must agree
double steady = 0.02; // largest frame-to-frame brightness change
// Auto-exposure limits; negative = unknown, and the limit test is off.
double exposure_min_us = -1.0;
double exposure_max_us = -1.0;
double gain_min_db = -1.0;
double gain_max_db = -1.0;
};
enum class Verdict { Waiting, Settled, AtLimit };
SettleRule() = default;
explicit SettleRule(const Params& p) : p_(p) {}
void setParams(const Params& p) { p_ = p; reset(); }
const Params& params() const { return p_; }
// Start over (the gimbal stopped at a new waypoint).
void reset() { window_.clear(); }
// Feed the next frame, in arrival order. A frame without a brightness
// breaks the run of agreeing frames.
Verdict add(const FrameStats& f);
// The band on the 0..255 scale frames are sampled on.
double bandLow() const { return (p_.target_pct - p_.tolerance_pct) * 2.55; }
double bandHigh() const { return (p_.target_pct + p_.tolerance_pct) * 2.55; }
private:
bool steady() const;
bool atLimit() const;
Params p_;
std::deque<FrameStats> window_; // the last p_.frames frames
};
} // namespace fgc

View File

@ -38,6 +38,9 @@ public:
std::vector<CameraDeviceInfo> deviceInfo() override;
bool grabFrame(int camera_index, Frame& out, int timeout_ms) override;
bool setFeature(int camera_index, const std::string& name, const std::string& value) override;
uint64_t frameSeq() override;
std::vector<FrameStats> framesSince(uint64_t after) override;
bool readFeature(int camera_index, const std::string& name, double& value) override;
bool featureRange(int camera_index, const std::string& name, double& min, double& max) override;
bool restoreProfile(int camera_index) override;
bool resetCamera(int camera_index) override;

View File

@ -12,14 +12,16 @@
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. 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.
// a small gradient frame (varying with time so successive captures differ) and
// delivers it to the frame callback - as RGB8 by default, or as the raw mosaic
// (BayerRG8, BayerRG12p) a raw-capture profile would deliver. 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)
: count_(count), width_(width), height_(height) {}
explicit MockCameraSource(int count = 1, uint32_t width = 640, uint32_t height = 480,
PixelLayout layout = PixelLayout::RGB8)
: count_(count), width_(width), height_(height), layout_(layout) {}
void open() override { LOG_INFO << "[mock] camera opened (" << count_ << ")"; }
void close() override {}
@ -38,6 +40,28 @@ public:
}
void setFrameCallback(FrameCallback cb) override { callback_ = std::move(cb); }
// A free-running stream whose auto-exposure has settled: every call to
// framesSince() "delivers" one more frame at the camera's target brightness
// (50% of full scale), so the keep rule settles after a few scheduler ticks.
uint64_t frameSeq() override { return stream_seq_; }
std::vector<FrameStats> framesSince(uint64_t after) override {
++stream_seq_;
std::vector<FrameStats> out;
for (uint64_t s = after + 1; s <= stream_seq_; ++s)
out.push_back(FrameStats{s, 127.5, 10000.0, 6.0});
return out;
}
bool readFeature(int camera_index, const std::string& name, double& value) override {
if (camera_index < 0 || camera_index >= count_) return false;
if (name == "IntensityControllerTarget") { value = 50.0; return true; }
if (name == "IntensityControllerTolerance") { value = 5.0; return true; }
if (name == "ExposureAutoMin") { value = 82.0; return true; }
if (name == "ExposureAutoMax") { value = 20000.0; return true; }
if (name == "GainAutoMin") { value = 0.0; return true; }
if (name == "GainAutoMax") { value = 24.0; return true; }
return false;
}
int cameraCount() const override { return count_; }
bool setProfile(int camera_index, const CameraProfile& profile) override {
@ -101,7 +125,7 @@ public:
d.streaming = started_;
d.width = width_;
d.height = height_;
d.payload_bytes = static_cast<long long>(width_) * height_ * 3;
d.payload_bytes = static_cast<long long>(layoutFrameBytes(layout_, width_, height_));
d.actual_fps = 1.0;
d.frames_delivered = (i == 0) ? frames_ : 0; // only cam0 produces frames
out.push_back(std::move(d));
@ -152,12 +176,46 @@ private:
f.data[i + 2] = clamp8(level + tex * 0.6);
}
}
f.layout = PixelLayout::RGB8;
if (layoutIsBayer(layout_)) f = mosaic(f);
f.brightness = sampleBrightness(f.data.data(), f.data.size(), f.layout, f.width, f.height);
return f;
}
// The RGB frame as the sensor would see it: each pixel keeps only the colour
// of its filter (BayerRG: R G / G B), 8-bit or packed 12-bit.
Frame mosaic(const Frame& rgb) const {
Frame f = rgb;
f.layout = layout_;
f.channels = 1;
const size_t n = static_cast<size_t>(width_) * height_;
std::vector<uint16_t> m(n);
for (uint32_t y = 0; y < height_; ++y)
for (uint32_t x = 0; x < width_; ++x) {
const int c = (y % 2 == 0) ? (x % 2 == 0 ? 0 : 1) : (x % 2 == 0 ? 1 : 2);
m[static_cast<size_t>(y) * width_ + x] =
rgb.data[(static_cast<size_t>(y) * width_ + x) * 3 + c];
}
f.data.assign(layoutFrameBytes(layout_, width_, height_), 0);
if (layout_ == PixelLayout::BayerRG8) {
for (size_t i = 0; i < n; ++i) f.data[i] = static_cast<uint8_t>(m[i]);
} else { // BayerRG12p: the 8-bit value << 4, packed two pixels in three bytes
for (size_t i = 0, o = 0; i < n; i += 2, o += 3) {
const uint16_t p0 = static_cast<uint16_t>(m[i] << 4);
const uint16_t p1 = i + 1 < n ? static_cast<uint16_t>(m[i + 1] << 4) : 0;
f.data[o] = static_cast<uint8_t>(p0 & 0xFF);
f.data[o + 1] = static_cast<uint8_t>(((p0 >> 8) & 0x0F) | ((p1 & 0x0F) << 4));
if (o + 2 < f.data.size()) f.data[o + 2] = static_cast<uint8_t>(p1 >> 4);
}
}
return f;
}
int count_;
uint32_t width_;
uint32_t height_;
PixelLayout layout_;
uint64_t stream_seq_ = 1;
bool started_ = false;
long long frames_ = 0;
std::string profile_;

View File

@ -47,44 +47,62 @@ except ImportError:
# read-only - until it is off. A feature the camera does not have is reported
# and skipped, not fatal: the fleet runs several Alvium generations and their
# feature vocabularies differ.
# Raw capture (docs/raw12-pipeline-measurements.md): full resolution, packed
# 12-bit Bayer, de-Bayered on the tower. At the 250 MB/s link cap that is
# 9.86 fps on the 16 MP 1800 U-1620c (RGB8: 4.95), so auto-exposure settles in
# about half the time, with no lost frames measured on either computer.
# Binning goes to 1 vertical-first (the horizontal minimum depends on it), and
# Width/Height ask for more than any sensor so the clamp lands on the maximum.
# The per-frame chunk data is what the keep rule reads; each item must be
# enabled before chunk mode itself, which locks them.
_RAW_COMMON = [
("TriggerSelector", "FrameStart"),
("TriggerMode", "Off"),
("BinningVertical", 1),
("BinningHorizontal", 1),
("OffsetX", 0),
("OffsetY", 0),
("Width", 100000),
("Height", 100000),
("PixelFormat", "BayerRG12p"),
("DeviceLinkThroughputLimit", 250000000),
("AcquisitionFrameRateEnable", True),
("AcquisitionFrameRate", 1000.0), # clamped to the camera's maximum
("ChunkModeActive", False),
("ChunkSelector", "ExposureTime"),
("ChunkEnable", True),
("ChunkSelector", "Gain"),
("ChunkEnable", True),
("ChunkSelector", "BalanceRatioRed"),
("ChunkEnable", True),
("ChunkSelector", "BalanceRatioBlue"),
("ChunkEnable", True),
("ChunkSelector", "Timestamp"),
("ChunkEnable", True),
("ChunkModeActive", True),
("BalanceWhiteAuto", "Off"), # fixed ratios, as before
("ExposureAuto", "Continuous"),
("GainAuto", "Continuous"),
("IntensityControllerRate", 100), # fastest
]
RECIPES = {
# Daylight. 2x2 binning is not optional at 10 Hz: unbinned RGB8 on a 16 MP
# sensor is a 48 MB frame, which is both over the USB ceiling in
# known-issues #19 and far too slow to reach 10 fps under any throughput
# limit this camera offers.
"standard": [
("TriggerSelector", "FrameStart"),
("TriggerMode", "Off"),
("BinningHorizontal", 2),
("BinningVertical", 2),
("PixelFormat", "RGB8"),
("DeviceLinkThroughputLimit", 250000000),
("AcquisitionFrameRateEnable", True),
("AcquisitionFrameRate", 10.0),
("ExposureAuto", "Continuous"),
# Daylight. Exposure capped at 20 ms; the gimbal is at rest when a frame is
# kept, so noise matters more than motion blur.
"standard": _RAW_COMMON + [
("ExposureAutoMin", 200.0),
("ExposureAutoMax", 20000.0),
("GainAuto", "Continuous"),
("GainAutoMax", 24.0),
("IntensityAutoPrecedence", "MinimizeBlur"),
("IntensityAutoPrecedence", "MinimizeNoise"),
("DeviceIndicatorMode", "Active"),
("DeviceIndicatorLuminance", 10),
],
# Night: 1 Hz, long exposures, more gain allowed, noise over motion blur,
# and the status LED off so it cannot contaminate a long exposure.
"night": [
("TriggerSelector", "FrameStart"),
("TriggerMode", "Off"),
("BinningHorizontal", 2),
("BinningVertical", 2),
("PixelFormat", "RGB8"),
("DeviceLinkThroughputLimit", 250000000),
("AcquisitionFrameRateEnable", True),
("AcquisitionFrameRate", 1.0),
("ExposureAuto", "Continuous"),
# Night: long exposures (the frame rate then follows the exposure), more gain
# allowed, and the status LED off so it cannot contaminate a long exposure.
# 12 bits matter most here: deep shadows are where they carry information.
"night": _RAW_COMMON + [
("ExposureAutoMin", 1000.0),
("ExposureAutoMax", 500000.0),
("GainAuto", "Continuous"),
("GainAutoMax", 48.0),
("IntensityAutoPrecedence", "MinimizeNoise"),
("DeviceIndicatorMode", "Inactive"),
@ -180,13 +198,11 @@ def main():
w = cam.get_feature_by_name("Width").get()
h = cam.get_feature_by_name("Height").get()
fmt = cam.get_feature_by_name("PixelFormat").get()
bpp = 3 if str(fmt) in ("RGB8", "BGR8") else 1
bpp = {"RGB8": 3, "BGR8": 3, "BayerRG12p": 1.5, "BayerRG12": 2}.get(str(fmt), 1)
rate = cam.get_feature_by_name("AcquisitionFrameRate").get()
mb = w * h * bpp / 1e6
print(f"\nresult : {w}x{h} {fmt} {mb:.1f} MB/frame "
f"{rate:.2f} fps = {mb * rate:.0f} MB/s")
if mb > 20:
print("WARNING : over the ~20 MB/frame ceiling in known-issues #19")
except Exception:
pass

View File

@ -2,7 +2,9 @@
#include "fgc/JpegXlEncoder.h"
#include "fgc/Logger.h"
#include "fgc/RawImage.h"
#include <algorithm>
#include <filesystem>
#include <opencv2/core.hpp>
@ -35,13 +37,26 @@ ImagePipeline::~ImagePipeline() { stop(); }
void ImagePipeline::start() {
if (running_.exchange(true)) return;
worker_ = std::thread(&ImagePipeline::run, this);
const int n = std::max(1, params_.workers);
for (int i = 0; i < n; ++i) workers_.emplace_back(&ImagePipeline::run, this);
}
void ImagePipeline::stop() {
if (!running_.exchange(false)) return;
cv_.notify_all();
if (worker_.joinable()) worker_.join();
for (auto& w : workers_)
if (w.joinable()) w.join();
workers_.clear();
}
bool ImagePipeline::canAccept() const {
std::lock_guard<std::mutex> lock(mutex_);
return queue_.size() + in_flight_ < static_cast<size_t>(std::max(1, params_.queue_max));
}
size_t ImagePipeline::pending() const {
std::lock_guard<std::mutex> lock(mutex_);
return queue_.size() + in_flight_;
}
void ImagePipeline::submit(const Frame& frame) {
@ -65,8 +80,11 @@ void ImagePipeline::run() {
if (!running_ && queue_.empty()) break;
queued = std::move(queue_.front());
queue_.pop();
++in_flight_;
}
process(queued);
std::lock_guard<std::mutex> lock(mutex_);
--in_flight_;
}
}
@ -81,22 +99,18 @@ const ImagePipeline::CameraOutput& ImagePipeline::outputFor(int cam_id) const {
void ImagePipeline::process(const QueuedFrame& queued) {
const Frame& frame = queued.frame;
if (frame.channels != 1 && frame.channels != 3) {
const bool known = frame.layout != PixelLayout::Unknown;
if (!known && frame.channels != 1 && frame.channels != 3) {
LOG_WARN << "Dropping frame with unsupported channel count " << frame.channels;
return;
}
if (known && frame.data.size() < layoutFrameBytes(frame.layout, frame.width, frame.height)) {
LOG_WARN << "Dropping " << layoutName(frame.layout) << " frame: " << frame.data.size()
<< " bytes is short of " << frame.width << 'x' << frame.height;
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<uint8_t*>(frame.data.data()));
cv::Mat rotated;
int code = 0;
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;
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;
@ -107,12 +121,7 @@ void ImagePipeline::process(const QueuedFrame& queued) {
return;
}
const fs::path file = dir / (std::to_string(frame.timestamp_ms) + ".jxl");
if (!JpegXlEncoder::encodeToFile(file.string(), img.data, img.cols, img.rows,
frame.channels, out.jxl_distance, out.jxl_effort)) {
LOG_ERROR << "Failed to encode " << file.string();
return;
}
if (!save(frame, out, file.string())) return;
// Sampled at submit(), not here: by this point the encode above has run and
// the gimbal may already be slewing to the next waypoint.
@ -130,7 +139,50 @@ void ImagePipeline::process(const QueuedFrame& queued) {
}
saved_.fetch_add(1);
LOG_DEBUG << "Saved " << file.string() << " (" << img.cols << "x" << img.rows << ")";
LOG_DEBUG << "Saved " << file.string() << " (" << frame.width << 'x' << frame.height << ' '
<< (known ? layoutName(frame.layout) : "legacy") << ')';
}
bool ImagePipeline::save(const Frame& frame, const CameraOutput& out, const std::string& file) {
int code = 0;
bool rotate = rotateCode(out.rotation_deg, code);
const int h = static_cast<int>(frame.height), w = static_cast<int>(frame.width);
auto* data = const_cast<uint8_t*>(frame.data.data());
cv::Mat img; // what gets encoded: 8-bit (CV_8U) or 12-bit values in CV_16U
int bits = 8;
if (frame.layout == PixelLayout::BayerRG8 || frame.layout == PixelLayout::BayerRG12p) {
cv::Mat mosaic;
if (frame.layout == PixelLayout::BayerRG8) {
mosaic = cv::Mat(h, w, CV_8UC1, data);
} else {
mosaic.create(h, w, CV_16UC1);
unpack12p(frame.data.data(), reinterpret_cast<uint16_t*>(mosaic.data),
static_cast<size_t>(w) * h);
bits = 12;
}
// The camera's BayerRG is OpenCV's BayerBG (see RawImage.h).
cv::cvtColor(mosaic, img,
out.edge_aware ? cv::COLOR_BayerBG2RGB_EA : cv::COLOR_BayerBG2RGB);
} else {
// RGB8, Mono8, or a legacy frame described by its channel count. The
// Mat only wraps frame.data, so an unrotated frame costs no copy.
img = cv::Mat(h, w, frame.channels == 3 ? CV_8UC3 : CV_8UC1, data);
}
if (rotate) {
cv::Mat rotated;
cv::rotate(img, rotated, code);
img = rotated;
}
if (!img.isContinuous()) img = img.clone();
if (!JpegXlEncoder::encodeToFile(file, img.data, img.cols, img.rows, img.channels(), bits,
static_cast<float>(out.jxl_distance), out.jxl_effort,
params_.jxl_threads)) {
LOG_ERROR << "Failed to encode " << file;
return false;
}
return true;
}
std::optional<CamEvent> ImagePipeline::lastEvent() const {

View File

@ -12,28 +12,43 @@
namespace fgc {
bool JpegXlEncoder::encodeToFile(const std::string& path, const uint8_t* img, int width,
int height, int channels, float distance, int effort,
bool JpegXlEncoder::encodeToFile(const std::string& path, const void* img, int width, int height,
int channels, int bits, float distance, int effort,
int threads) {
auto enc = JxlEncoderMake(nullptr);
auto runner = JxlThreadParallelRunnerMake(nullptr, threads);
if (JXL_ENC_SUCCESS !=
JxlEncoderSetParallelRunner(enc.get(), JxlThreadParallelRunner, runner.get())) {
LOG_ERROR << "JxlEncoderSetParallelRunner failed";
if (!img || width <= 0 || height <= 0 || (channels != 1 && channels != 3) || bits < 8 ||
bits > 16 || distance < 0.0f) {
LOG_ERROR << "JpegXlEncoder: bad arguments (" << width << 'x' << height << ", "
<< channels << " channels, " << bits << " bits, distance " << distance << ')';
return false;
}
const bool lossless = distance == 0.0f;
const bool wide = bits > 8;
JxlPixelFormat pixel_format = {static_cast<uint32_t>(channels), JXL_TYPE_UINT8,
JXL_NATIVE_ENDIAN, 0};
auto enc = JxlEncoderMake(nullptr);
JxlThreadParallelRunnerPtr runner;
if (threads > 0) {
runner = JxlThreadParallelRunnerMake(nullptr, static_cast<size_t>(threads));
if (JXL_ENC_SUCCESS !=
JxlEncoderSetParallelRunner(enc.get(), JxlThreadParallelRunner, runner.get())) {
LOG_ERROR << "JxlEncoderSetParallelRunner failed";
return false;
}
}
JxlPixelFormat pixel_format = {static_cast<uint32_t>(channels),
wide ? JXL_TYPE_UINT16 : JXL_TYPE_UINT8, JXL_NATIVE_ENDIAN, 0};
JxlBasicInfo basic_info;
JxlEncoderInitBasicInfo(&basic_info);
basic_info.xsize = width;
basic_info.ysize = height;
basic_info.alpha_bits = 0;
basic_info.bits_per_sample = 8;
basic_info.bits_per_sample = static_cast<uint32_t>(bits);
basic_info.num_color_channels = channels;
basic_info.uses_original_profile = JXL_FALSE;
// Lossless requires the original colour space; with it off, libjxl encodes
// through its lossy XYB space, reports success, and the pixels come back
// changed (known-issues #20). Lossy keeps XYB, as the program always has.
basic_info.uses_original_profile = lossless ? JXL_TRUE : JXL_FALSE;
if (JXL_ENC_SUCCESS != JxlEncoderSetBasicInfo(enc.get(), &basic_info)) {
LOG_ERROR << "JxlEncoderSetBasicInfo failed";
return false;
@ -49,17 +64,26 @@ bool JpegXlEncoder::encodeToFile(const std::string& path, const uint8_t* img, in
JxlEncoderFrameSettings* fs = JxlEncoderFrameSettingsCreate(enc.get(), nullptr);
JxlEncoderFrameSettingsSetOption(fs, JXL_ENC_FRAME_SETTING_EFFORT, effort);
JxlEncoderFrameSettingsSetOption(fs, JXL_ENC_FRAME_SETTING_DECODING_SPEED, 0);
if (distance == 0.0f) {
if (lossless) {
JxlEncoderSetFrameDistance(fs, 0);
JxlEncoderSetFrameLossless(fs, JXL_TRUE);
} else {
JxlEncoderSetFrameLossless(fs, JXL_FALSE);
JxlEncoderSetFrameDistance(fs, distance);
}
if (wide) {
// The 16-bit words hold `bits`-bit values: read them at the header's
// depth, not as the full 0..65535 range.
JxlBitDepth depth = {JXL_BIT_DEPTH_FROM_CODESTREAM, 0, 0};
if (JXL_ENC_SUCCESS != JxlEncoderSetFrameBitDepth(fs, &depth)) {
LOG_ERROR << "JxlEncoderSetFrameBitDepth failed";
return false;
}
}
const size_t byte_size = static_cast<size_t>(width) * height * channels;
if (JXL_ENC_SUCCESS !=
JxlEncoderAddImageFrame(fs, &pixel_format, img, byte_size)) {
const size_t byte_size =
static_cast<size_t>(width) * height * channels * (wide ? 2 : 1);
if (JXL_ENC_SUCCESS != JxlEncoderAddImageFrame(fs, &pixel_format, img, byte_size)) {
LOG_ERROR << "JxlEncoderAddImageFrame failed";
return false;
}
@ -91,7 +115,10 @@ bool JpegXlEncoder::encodeToFile(const std::string& path, const uint8_t* img, in
}
bool ok = std::fwrite(compressed.data(), 1, compressed.size(), file) == compressed.size();
if (std::fclose(file) != 0) ok = false;
if (!ok) LOG_ERROR << "Could not write " << path;
if (!ok) {
LOG_ERROR << "Could not write " << path;
std::remove(path.c_str());
}
return ok;
}

View File

@ -17,6 +17,7 @@
#include <algorithm>
#include <atomic>
#include <chrono>
#include <deque>
#include <memory>
#include <mutex>
#include <stdexcept>
@ -40,6 +41,17 @@ long long nowMs() {
// Cumulative per-camera acquisition stats. Lives in Impl (NOT the observer) so it
// survives the restart-on-stall, which recreates the observer each session.
struct CamStats {
// The last frames' brightness, exposure and gain, for the keep rule.
std::mutex recent_mutex;
std::deque<FrameStats> recent;
uint64_t seq = 0;
void pushRecent(const Frame& f) {
std::lock_guard<std::mutex> lock(recent_mutex);
recent.push_back(FrameStats{++seq, f.brightness.valid() ? f.brightness.mean : -1.0,
f.exposure_us, f.gain_db});
while (recent.size() > 64) recent.pop_front();
}
std::atomic<long long> delivered{0};
std::atomic<long long> dropped{0};
std::atomic<long long> restarts{0};
@ -48,6 +60,17 @@ struct CamStats {
std::atomic<long long> last_bytes{0};
};
// The program's name for a delivered pixel format; Unknown for anything else.
PixelLayout layoutOf(VmbPixelFormatType f) {
switch (f) {
case VmbPixelFormatMono8: return PixelLayout::Mono8;
case VmbPixelFormatRgb8: return PixelLayout::RGB8;
case VmbPixelFormatBayerRG8: return PixelLayout::BayerRG8;
case VmbPixelFormatBayerRG12p: return PixelLayout::BayerRG12p;
default: return PixelLayout::Unknown;
}
}
// Frame observer for the free-running stream: keeps the most recent COMPLETE
// frame. trigger() (once per scan waypoint) reads it out; the intermediate frames
// just give the on-camera auto-exposure/gain time to converge to the current
@ -85,10 +108,30 @@ public:
Frame f;
f.width = w;
f.height = h;
f.channels = static_cast<int>(sz / (static_cast<size_t>(w) * h));
f.cam_id = cam_id_;
f.timestamp_ms = nowMs();
f.data.assign(buf, buf + sz);
VmbPixelFormatType pf = VmbPixelFormatLast;
if (pFrame->GetPixelFormat(pf) == VmbErrorSuccess) f.layout = layoutOf(pf);
size_t bytes = sz;
if (f.layout != PixelLayout::Unknown) {
// The buffer can carry chunk data after the image; keep the image.
bytes = std::min<size_t>(sz, layoutFrameBytes(f.layout, w, h));
f.channels = f.layout == PixelLayout::RGB8 ? 3 : 1;
} else {
// A format the program does not handle: say what it holds, as
// before layouts existed, and let the pipeline refuse it.
f.channels = static_cast<int>(sz / (static_cast<size_t>(w) * h));
if (!warned_layout_) {
warned_layout_ = true;
LOG_WARN << "camera cam" << cam_id_ << ": pixel format 0x" << std::hex
<< pf << std::dec << " is not one the program handles "
<< "(Mono8, RGB8, BayerRG8, BayerRG12p)";
}
}
f.data.assign(buf, buf + bytes);
readChunks(pFrame, f);
f.brightness = sampleBrightness(f.data.data(), f.data.size(), f.layout, w, h);
if (stats_) stats_->pushRecent(f);
{
std::lock_guard<std::mutex> lock(mutex_);
latest_ = std::move(f);
@ -114,8 +157,32 @@ public:
}
private:
// Exposure, gain and white balance as the camera reports them for this
// frame. Only when the profile turns chunk mode on; after the first frame
// without chunk data, stop asking for the rest of the session.
void readChunks(const FramePtr& frame, Frame& f) {
if (!chunks_available_) return;
const VmbErrorType rc = frame->AccessChunkData([&f](ChunkFeatureContainerPtr& c) {
auto num = [&c](const char* name, double& out) {
FeaturePtr feat;
double v = 0;
if (c->GetFeatureByName(name, feat) == VmbErrorSuccess &&
feat->GetValue(v) == VmbErrorSuccess)
out = v;
};
num("ChunkExposureTime", f.exposure_us);
num("ChunkGain", f.gain_db);
num("ChunkBalanceRatioRed", f.wb_red);
num("ChunkBalanceRatioBlue", f.wb_blue);
return VmbErrorSuccess;
});
if (rc != VmbErrorSuccess) chunks_available_ = false;
}
int cam_id_;
CamStats* stats_;
bool warned_layout_ = false;
bool chunks_available_ = true;
std::mutex mutex_;
Frame latest_;
bool has_ = false;
@ -633,6 +700,39 @@ bool VimbaCameraSource::setFeature(int camera_index, const std::string& name,
return true;
}
uint64_t VimbaCameraSource::frameSeq() {
if (impl_->stats.empty()) return 0;
std::lock_guard<std::mutex> lock(impl_->stats[0]->recent_mutex);
return impl_->stats[0]->seq;
}
std::vector<FrameStats> VimbaCameraSource::framesSince(uint64_t after) {
std::vector<FrameStats> out;
if (impl_->stats.empty()) return out;
std::lock_guard<std::mutex> lock(impl_->stats[0]->recent_mutex);
for (const auto& f : impl_->stats[0]->recent)
if (f.seq > after) out.push_back(f);
return out;
}
bool VimbaCameraSource::readFeature(int camera_index, const std::string& name, double& value) {
const size_t idx = static_cast<size_t>(camera_index);
if (camera_index < 0 || idx >= impl_->cameras.size()) return false;
FeaturePtr f;
VmbFeatureDataType type = VmbFeatureDataUnknown;
if (SP_ACCESS(impl_->cameras[idx])->GetFeatureByName(name.c_str(), f) != VmbErrorSuccess ||
f->GetDataType(type) != VmbErrorSuccess)
return false;
if (type == VmbFeatureDataFloat) return f->GetValue(value) == VmbErrorSuccess;
if (type == VmbFeatureDataInt) {
VmbInt64_t v = 0;
if (f->GetValue(v) != VmbErrorSuccess) return false;
value = static_cast<double>(v);
return true;
}
return false;
}
bool VimbaCameraSource::featureRange(int camera_index, const std::string& name, double& min,
double& max) {
const size_t idx = static_cast<size_t>(camera_index);

View File

@ -20,6 +20,7 @@
#include "fgc/IImuSource.h"
#include "fgc/IMotorController.h"
#include "fgc/ImagePipeline.h"
#include "fgc/SettleRule.h"
#include "fgc/Logger.h"
#include "fgc/MountedImuSource.h"
#include "fgc/MtiImuSource.h"
@ -502,22 +503,47 @@ struct Application::Impl {
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);
}
configureSettle();
LOG_INFO << "camera '" << cam_name << "' profile -> " << name;
return true;
}
// How long the scan waits after standstill, and how it decides the frame is
// good to keep, from the active profiles. The gimbal must stand still long
// enough for every camera, so the waits are the longest across them; the
// keep rule is on if any active profile asks for it, judged on camera 0's
// frames against the camera's own auto-exposure target and limits.
void configureSettle() {
if (!scheduler) return;
int settle = 0, timeout = 0;
bool rule = false;
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);
rule = rule || t.settle_rule == "brightness";
}
scheduler->setSettleWait(settle, timeout);
SettleRule::Params p;
if (rule && camera) {
double v = 0;
if (camera->readFeature(0, "IntensityControllerTarget", v)) p.target_pct = v;
if (camera->readFeature(0, "IntensityControllerTolerance", v)) p.tolerance_pct = v;
if (camera->readFeature(0, "ExposureAutoMin", v)) p.exposure_min_us = v;
if (camera->readFeature(0, "ExposureAutoMax", v)) p.exposure_max_us = v;
if (camera->readFeature(0, "GainAutoMin", v)) p.gain_min_db = v;
if (camera->readFeature(0, "GainAutoMax", v)) p.gain_max_db = v;
}
scheduler->setSettleRule(rule, p);
if (rule)
LOG_INFO << "keep rule: brightness in " << p.target_pct << " +- " << p.tolerance_pct
<< " % and steady for " << p.frames << " frames, after " << settle
<< " ms, within " << timeout << " ms";
else
LOG_INFO << "keep rule: fixed wait of " << settle << " ms after standstill";
}
// Comma-joined "label (type N)" for every XKF profile the device reports, so a
// bad argument can say what IS available instead of just refusing.
std::string imuProfileList() const {
@ -3014,7 +3040,13 @@ struct Application::Impl {
// 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});
pp.cameras.push_back({c->label, c->rotation_deg, c->jxl_distance, c->jxl_effort,
c->demosaic == "edge_aware"});
pp.workers = cfg.hardware.save_workers;
pp.jxl_threads = cfg.hardware.jxl_threads;
pp.queue_max = cfg.hardware.save_queue_max;
LOG_INFO << "image saving: " << pp.workers << " worker(s) x " << pp.jxl_threads
<< " thread(s), up to " << pp.queue_max << " frame(s) waiting";
// Orientation supplier, called by ImagePipeline::submit() as each frame
// arrives. The two axes come from different sources on purpose:
//
@ -3066,9 +3098,12 @@ struct Application::Impl {
scheduler = std::make_unique<CaptureScheduler>(*motor, *camera, cfg.image_rate(),
cfg.geometry, grid);
scheduler->setPanStep(cfg.hardware.pan_step_deg);
// A scan that outruns saving holds at its waypoint instead of piling
// frames up in memory (hardware.ini save_queue_max).
scheduler->setSaveGate([pipe_ptr] { return pipe_ptr->canAccept(); });
// 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());
configureSettle();
// Before the firmware is talked to: the registers are wanted by the very
// first sendAnchorConfig(), on both the homing and the no-homing startup path.

View File

@ -4,6 +4,7 @@
#include "fgc/Logger.h"
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <optional>
@ -43,6 +44,7 @@ void CaptureScheduler::setMode(Mode mode, const PanSpec& pan) {
pan_unreachable_warned_ = false;
moving_ = false; // restart the move/settle cycle cleanly
settled_at_ms_ = 0;
rule_started_ = false;
// Drop any armed snapshot: a scan starting means the operator moved on, and
// keeping it would fire an image at some unrelated later moment when the scan
// stops - attributed to whatever the gimbal happened to be pointing at then.
@ -62,6 +64,12 @@ void CaptureScheduler::setContinuous(bool on) {
resetTimer();
}
void CaptureScheduler::setSettleRule(bool on, const SettleRule::Params& params) {
use_rule_ = on;
rule_.setParams(params);
rule_started_ = false;
}
void CaptureScheduler::setSettleWait(int settle_ms, int timeout_ms) {
settle_wait_ms_ = settle_ms < 0 ? 0 : settle_ms;
frame_timeout_ms_ = timeout_ms < 0 ? 0 : timeout_ms;
@ -85,6 +93,18 @@ bool CaptureScheduler::settledAt(const MotorTelemetry& t) const {
std::labs(t.pitch.xenc - pitch_target_) <= kSettleTolCounts;
}
bool CaptureScheduler::roomToSave() {
const bool room = !can_save_ || can_save_();
if (!room && !holding_) {
holding_ = true;
LOG_WARN << "saving is behind: holding the scan here until it catches up";
} else if (room && holding_) {
holding_ = false;
LOG_INFO << "saving caught up: the scan carries on";
}
return room;
}
void CaptureScheduler::requestSnapshot() {
snapshot_pending_ = true;
snapshot_settled_ms_ = 0;
@ -137,6 +157,7 @@ void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) {
void CaptureScheduler::serviceContinuous(const MotorTelemetry& t) {
const double interval_ms = image_rate_ > 0.0 ? 1000.0 / image_rate_ : 0.0;
if (interval_ms <= 0.0 || elapsedMs() <= interval_ms) return;
if (continuous_settled_ms_ == 0 && !roomToSave()) return; // never mid-settle
switch (shootAtRest(t, continuous_settled_ms_)) {
case Shot::Waiting:
return;
@ -171,8 +192,10 @@ void CaptureScheduler::tick() {
const double interval_ms = image_rate_ > 0.0 ? 1000.0 / image_rate_ : 0.0;
if (!moving_) {
// Issue the next move once the interval has elapsed.
// Issue the next move once the interval has elapsed, and only while
// there is room to save what it will capture.
if (interval_ms <= 0.0 || elapsedMs() <= interval_ms) return;
if (!roomToSave()) return;
if (mode_ == Mode::Auto) {
if (grid_.empty()) return;
@ -227,6 +250,7 @@ void CaptureScheduler::tick() {
sendMove();
moving_ = true;
settled_at_ms_ = 0;
rule_started_ = false;
resetTimer();
return;
}
@ -248,6 +272,37 @@ void CaptureScheduler::tick() {
const long long since_settled = now_ms_() - settled_at_ms_;
if (since_settled < settle_wait_ms_) return;
// The keep rule: from here on, watch the frames the camera delivers until
// brightness says the image has settled, or the frame budget runs out.
if (use_rule_) {
if (!rule_started_) {
rule_started_ = true;
rule_.reset();
rule_seq_ = camera_.frameSeq();
// No frame data from this camera: keep the fixed wait's behaviour.
if (rule_seq_ == 0) rule_seq_ = UINT64_MAX;
}
if (rule_seq_ != UINT64_MAX) {
SettleRule::Verdict v = SettleRule::Verdict::Waiting;
for (const auto& f : camera_.framesSince(rule_seq_)) {
rule_seq_ = f.seq;
v = rule_.add(f);
if (v != SettleRule::Verdict::Waiting) break;
}
if (v == SettleRule::Verdict::Waiting) {
if (since_settled < settle_wait_ms_ + frame_timeout_ms_) return; // keep watching
LOG_WARN << "the image did not settle within " << frame_timeout_ms_
<< " ms of standstill; keeping the newest frame";
} else {
LOG_TRACE_CAT(LogCat::Control)
<< "image settled after " << since_settled << " ms"
<< (v == SettleRule::Verdict::AtLimit ? " (exposure and gain at their limit)"
: "");
}
rule_seq_ = UINT64_MAX; // decided: from here on, only the trigger retries
}
}
if (camera_.trigger()) {
if (mode_ == Mode::Auto)
LOG_INFO << "captured waypoint " << (grid_.index() + 1) << "/" << grid_.size();
@ -267,6 +322,7 @@ void CaptureScheduler::tick() {
moving_ = false;
settled_at_ms_ = 0;
rule_started_ = false;
if (mode_ == Mode::Auto) grid_.advance();
else if (!pan_headings_.empty()) pan_index_ = (pan_index_ + 1) % pan_headings_.size();
resetTimer();

View File

@ -277,6 +277,18 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
throw std::runtime_error("Hardware.pan_step_deg must be > 0 (degrees of bearing "
"one picture covers)");
}
cfg.hardware.save_workers = getInt(kv, "Hardware.save_workers", cfg.hardware.save_workers);
cfg.hardware.jxl_threads = getInt(kv, "Hardware.jxl_threads", cfg.hardware.jxl_threads);
cfg.hardware.save_queue_max = getInt(kv, "Hardware.save_queue_max", cfg.hardware.save_queue_max);
if (cfg.hardware.save_workers < 1 || cfg.hardware.save_workers > 4)
throw std::runtime_error("Hardware.save_workers must be in 1..4 (the boards have 4 cores)");
if (cfg.hardware.jxl_threads < 0 || cfg.hardware.jxl_threads > 8)
throw std::runtime_error("Hardware.jxl_threads must be in 0..8 (0 = 4 / save_workers)");
if (cfg.hardware.jxl_threads == 0)
cfg.hardware.jxl_threads = std::max(1, 4 / cfg.hardware.save_workers);
if (cfg.hardware.save_queue_max < 1 || cfg.hardware.save_queue_max > 32)
throw std::runtime_error("Hardware.save_queue_max must be in 1..32 (each raw frame "
"waiting holds ~24 MB)");
for (const auto& a : cfg.hardware.axes)
if (a != "yaw" && a != "pitch")
throw std::runtime_error("Hardware.axes: unknown axis '" + a +
@ -296,6 +308,7 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
// ---- [Camera] shared defaults, then one [Camera.<name>] 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);
cfg.camera.demosaic = lowered(get(kv, "Camera.demosaic", cfg.camera.demosaic));
// 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.
@ -330,6 +343,7 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
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);
c.demosaic = lowered(get(kv, pre + "demosaic", cfg.camera.demosaic));
// Per-profile timing: Camera.<name>.<profile>.settle_wait_ms
for (const auto& [key, val] : kv) {
@ -343,8 +357,12 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
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)");
else if (field == "settle_rule") t.settle_rule = lowered(get(kv, key));
else throw std::runtime_error("unknown key '" + key + "' (expected " + pre + profile +
".settle_wait_ms, .frame_timeout_ms or .settle_rule)");
if (t.settle_rule != "fixed" && t.settle_rule != "brightness")
throw std::runtime_error(key.substr(0, key.rfind('.')) + ".settle_rule must be "
"'fixed' or 'brightness'");
}
if (c.driver != "vimba")
@ -358,6 +376,9 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
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.demosaic != "edge_aware" && c.demosaic != "bilinear")
throw std::runtime_error("Camera." + name + ".demosaic must be 'edge_aware' or "
"'bilinear' (got '" + c.demosaic + "')");
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");

94
src/core/RawImage.cpp Normal file
View File

@ -0,0 +1,94 @@
#include "fgc/RawImage.h"
namespace fgc {
const char* layoutName(PixelLayout layout) {
switch (layout) {
case PixelLayout::Mono8: return "Mono8";
case PixelLayout::RGB8: return "RGB8";
case PixelLayout::BayerRG8: return "BayerRG8";
case PixelLayout::BayerRG12p: return "BayerRG12p";
case PixelLayout::Unknown: break;
}
return "Unknown";
}
PixelLayout layoutFromName(const std::string& name) {
for (PixelLayout l : {PixelLayout::Mono8, PixelLayout::RGB8, PixelLayout::BayerRG8,
PixelLayout::BayerRG12p})
if (name == layoutName(l)) return l;
return PixelLayout::Unknown;
}
bool layoutIsBayer(PixelLayout layout) {
return layout == PixelLayout::BayerRG8 || layout == PixelLayout::BayerRG12p;
}
int layoutBits(PixelLayout layout) {
switch (layout) {
case PixelLayout::Mono8:
case PixelLayout::RGB8:
case PixelLayout::BayerRG8: return 8;
case PixelLayout::BayerRG12p: return 12;
case PixelLayout::Unknown: break;
}
return 0;
}
size_t layoutFrameBytes(PixelLayout layout, uint32_t width, uint32_t height) {
const size_t n = static_cast<size_t>(width) * height;
switch (layout) {
case PixelLayout::Mono8:
case PixelLayout::BayerRG8: return n;
case PixelLayout::RGB8: return n * 3;
case PixelLayout::BayerRG12p: return (n * 3 + 1) / 2;
case PixelLayout::Unknown: break;
}
return 0;
}
namespace {
inline uint16_t px12p(const uint8_t* in, size_t i) {
const uint8_t* p = in + (i >> 1) * 3;
return (i & 1) == 0 ? static_cast<uint16_t>(p[0] | ((p[1] & 0x0F) << 8))
: static_cast<uint16_t>((p[1] >> 4) | (p[2] << 4));
}
} // namespace
void unpack12p(const uint8_t* in, uint16_t* out, size_t pixels) {
const size_t pairs = pixels / 2;
for (size_t k = 0; k < pairs; ++k, in += 3, out += 2) {
out[0] = static_cast<uint16_t>(in[0] | ((in[1] & 0x0F) << 8));
out[1] = static_cast<uint16_t>((in[1] >> 4) | (in[2] << 4));
}
if (pixels & 1) out[0] = static_cast<uint16_t>(in[0] | ((in[1] & 0x0F) << 8));
}
BrightnessSample sampleBrightness(const uint8_t* frame, size_t bytes, PixelLayout layout,
uint32_t width, uint32_t height, size_t stride) {
BrightnessSample s;
const size_t pixels = static_cast<size_t>(width) * height;
if (!frame || layout == PixelLayout::Unknown || stride == 0 ||
bytes < layoutFrameBytes(layout, width, height))
return s;
// Values per pixel (RGB8 has three); sampling every stride-th value keeps
// the colour channels mixed for RGB8 just as for a mosaic.
const size_t values = layout == PixelLayout::RGB8 ? pixels * 3 : pixels;
double sum = 0;
size_t hi = 0;
for (size_t i = 0; i < values; i += stride) {
double v = layout == PixelLayout::BayerRG12p ? px12p(frame, i) / 16.0 : frame[i];
sum += v;
hi += v >= 250.0;
++s.samples;
}
if (s.samples) {
s.mean = sum / s.samples;
s.clipped = static_cast<double>(hi) / s.samples;
}
return s;
}
} // namespace fgc

43
src/core/SettleRule.cpp Normal file
View File

@ -0,0 +1,43 @@
#include "fgc/SettleRule.h"
#include <cmath>
namespace fgc {
SettleRule::Verdict SettleRule::add(const FrameStats& f) {
if (f.brightness < 0) {
window_.clear();
return Verdict::Waiting;
}
window_.push_back(f);
const size_t n = p_.frames < 1 ? 1 : static_cast<size_t>(p_.frames);
while (window_.size() > n) window_.pop_front();
if (window_.size() < n || !steady()) return Verdict::Waiting;
bool in_band = true;
for (const auto& w : window_)
in_band = in_band && w.brightness >= bandLow() && w.brightness <= bandHigh();
if (in_band) return Verdict::Settled;
return atLimit() ? Verdict::AtLimit : Verdict::Waiting;
}
bool SettleRule::steady() const {
for (size_t i = 1; i < window_.size(); ++i) {
const double a = window_[i - 1].brightness, b = window_[i].brightness;
if (std::fabs(b - a) > p_.steady * std::fmax(a, 1.0)) return false;
}
return true;
}
bool SettleRule::atLimit() const {
const FrameStats& f = window_.back();
if (f.exposure_us < 0 || f.gain_db < 0) return false;
// A hair of slack: the camera reports quantised exposure and gain.
if (f.brightness < bandLow() && p_.exposure_max_us > 0 && p_.gain_max_db >= 0)
return f.exposure_us >= p_.exposure_max_us * 0.99 && f.gain_db >= p_.gain_max_db - 0.1;
if (f.brightness > bandHigh() && p_.exposure_min_us > 0 && p_.gain_min_db >= 0)
return f.exposure_us <= p_.exposure_min_us * 1.01 && f.gain_db <= p_.gain_min_db + 0.1;
return false;
}
} // namespace fgc

View File

@ -57,6 +57,8 @@ add_executable(fgc_tests
test_testreport.cpp
test_hostmetrics.cpp
test_testrunner.cpp
test_rawimage.cpp
test_settlerule.cpp
)
target_link_libraries(fgc_tests PRIVATE fgc_core doctest::doctest)
# So a test can read the shipped config/config.example.ini and hold it to what
@ -65,3 +67,13 @@ target_compile_definitions(fgc_tests PRIVATE FGC_SOURCE_DIR="${PROJECT_SOURCE_DI
include(${doctest_SOURCE_DIR}/scripts/cmake/doctest.cmake)
doctest_discover_tests(fgc_tests)
# Image tests: the JPEG XL encoder and the frame-to-image pipeline, which need
# OpenCV and libjxl (fgc_imaging) - still no camera SDK.
add_executable(fgc_imaging_tests
doctest_main.cpp
test_jpegxl.cpp
test_imagepipeline.cpp
)
target_link_libraries(fgc_imaging_tests PRIVATE fgc_imaging doctest::doctest)
doctest_discover_tests(fgc_imaging_tests)

3574
tests/data/ae_steps.csv Normal file

File diff suppressed because it is too large Load Diff

57
tests/jxl_test_decode.h Normal file
View File

@ -0,0 +1,57 @@
#pragma once
// Decoding JPEG XL files in tests (libjxl), shared by the image tests.
#include <jxl/decode.h>
#include <jxl/decode_cxx.h>
#include <cstdint>
#include <fstream>
#include <iterator>
#include <string>
#include <vector>
namespace fgc_test {
struct Decoded {
std::vector<uint16_t> px; // values at the file's own bit depth
int bits = 0;
int channels = 0;
bool original_profile = false;
};
// Decode to 16-bit words at the file's own bit depth (12-bit files give
// 0..4095, 8-bit files 0..255).
inline Decoded decode(const std::string& path, int channels) {
std::ifstream f(path, std::ios::binary);
std::vector<uint8_t> data{std::istreambuf_iterator<char>(f), {}};
Decoded out;
auto dec = JxlDecoderMake(nullptr);
JxlDecoderSubscribeEvents(dec.get(), JXL_DEC_BASIC_INFO | JXL_DEC_FULL_IMAGE);
JxlDecoderSetInput(dec.get(), data.data(), data.size());
JxlDecoderCloseInput(dec.get());
JxlPixelFormat pf = {static_cast<uint32_t>(channels), JXL_TYPE_UINT16, JXL_NATIVE_ENDIAN, 0};
for (;;) {
auto s = JxlDecoderProcessInput(dec.get());
if (s == JXL_DEC_BASIC_INFO) {
JxlBasicInfo bi;
JxlDecoderGetBasicInfo(dec.get(), &bi);
out.bits = static_cast<int>(bi.bits_per_sample);
out.channels = static_cast<int>(bi.num_color_channels);
out.original_profile = bi.uses_original_profile;
} else if (s == JXL_DEC_NEED_IMAGE_OUT_BUFFER) {
size_t n = 0;
JxlDecoderImageOutBufferSize(dec.get(), &pf, &n);
out.px.resize(n / 2);
JxlDecoderSetImageOutBuffer(dec.get(), &pf, out.px.data(), n);
JxlBitDepth bd = {JXL_BIT_DEPTH_FROM_CODESTREAM, 0, 0};
JxlDecoderSetImageOutBitDepth(dec.get(), &bd);
} else if (s == JXL_DEC_FULL_IMAGE || s == JXL_DEC_SUCCESS) {
return out;
} else {
out.px.clear();
return out;
}
}
}
} // namespace fgc_test

View File

@ -807,3 +807,55 @@ TEST_CASE("a declaration beats a flag that disagrees, and the clash is reported"
CHECK(agree.enabled);
CHECK_FALSE(agree.conflict);
}
TEST_CASE("image saving and de-Bayer settings: defaults, sharing the cores, limits") {
auto base = [](std::map<std::string, std::string> extra) {
std::map<std::string, std::string> kv = {{"Camera.rgb.id", "DEV_1"},
{"Camera.rgb.profile_dir", "/tmp/p"}};
for (auto& [k, v] : extra) kv[k] = v;
return ConfigLoader::fromMap(kv);
};
AppConfig c = base({});
CHECK(c.hardware.save_workers == 1);
CHECK(c.hardware.jxl_threads == 4); // 4 / save_workers
CHECK(c.hardware.save_queue_max == 4);
REQUIRE(c.camera.cameras.size() == 1);
CHECK(c.camera.cameras[0].demosaic == "edge_aware");
// Two workers share the four cores unless told otherwise (the 3 Delta).
c = base({{"Hardware.save_workers", "2"}});
CHECK(c.hardware.jxl_threads == 2);
c = base({{"Hardware.save_workers", "2"}, {"Hardware.jxl_threads", "3"}});
CHECK(c.hardware.jxl_threads == 3);
// [Camera] default, overridden per camera; case does not matter.
c = base({{"Camera.demosaic", "Bilinear"}});
CHECK(c.camera.cameras[0].demosaic == "bilinear");
c = base({{"Camera.demosaic", "bilinear"}, {"Camera.rgb.demosaic", "edge_aware"}});
CHECK(c.camera.cameras[0].demosaic == "edge_aware");
CHECK_THROWS(base({{"Camera.rgb.demosaic", "vng"}}));
CHECK_THROWS(base({{"Hardware.save_workers", "0"}}));
CHECK_THROWS(base({{"Hardware.save_workers", "5"}}));
CHECK_THROWS(base({{"Hardware.jxl_threads", "9"}}));
CHECK_THROWS(base({{"Hardware.save_queue_max", "0"}}));
CHECK_THROWS(base({{"Hardware.save_queue_max", "33"}}));
}
TEST_CASE("each profile chooses its keep rule: fixed (default) or brightness") {
auto c = ConfigLoader::fromMap({{"Camera.rgb.id", "DEV_1"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{"Camera.rgb.standard.settle_rule", "Brightness"},
{"Camera.rgb.standard.frame_timeout_ms", "5000"},
{"Camera.rgb.night.settle_wait_ms", "2500"}});
REQUIRE(c.camera.cameras.size() == 1);
const auto& cam = c.camera.cameras[0];
CHECK(cam.timingFor("standard").settle_rule == "brightness");
CHECK(cam.timingFor("standard").frame_timeout_ms == 5000);
CHECK(cam.timingFor("night").settle_rule == "fixed");
CHECK(cam.timingFor("unnamed").settle_rule == "fixed");
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rgb.id", "DEV_1"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{"Camera.rgb.standard.settle_rule", "magic"}}));
}

View File

@ -0,0 +1,222 @@
#include "fgc/ImagePipeline.h"
#include "fgc/mock/MockCameraSource.h"
#include "jxl_test_decode.h"
#include <doctest/doctest.h>
#include <chrono>
#include <filesystem>
#include <mutex>
#include <thread>
using namespace fgc;
using fgc_test::decode;
namespace fs = std::filesystem;
namespace {
struct FakeChannel : IControlChannel {
std::mutex m;
std::vector<CamEvent> events;
bool connect() override { return true; }
void disconnect() override {}
bool connected() const override { return true; }
void publishStatus(int) override {}
void publishCamEvent(const CamEvent& e) override {
std::lock_guard<std::mutex> l(m);
events.push_back(e);
}
size_t count() {
std::lock_guard<std::mutex> l(m);
return events.size();
}
};
struct TempDir {
fs::path path;
TempDir() {
path = fs::temp_directory_path() /
("fgc_pipe_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
fs::create_directories(path);
}
~TempDir() { fs::remove_all(path); }
};
// A uniformly coloured raw frame: every red site `r`, green `g`, blue `b`
// (on the layout's own scale), in BayerRG order (R G / G B).
Frame bayerFrame(PixelLayout layout, uint32_t w, uint32_t h, int r, int g, int b, long long ts) {
Frame f;
f.width = w; f.height = h; f.layout = layout; f.channels = 1; f.timestamp_ms = ts;
std::vector<uint16_t> m(static_cast<size_t>(w) * h);
for (uint32_t y = 0; y < h; ++y)
for (uint32_t x = 0; x < w; ++x)
m[static_cast<size_t>(y) * w + x] = static_cast<uint16_t>(
y % 2 == 0 ? (x % 2 == 0 ? r : g) : (x % 2 == 0 ? g : b));
f.data.assign(layoutFrameBytes(layout, w, h), 0);
if (layout == PixelLayout::BayerRG8) {
for (size_t i = 0; i < m.size(); ++i) f.data[i] = static_cast<uint8_t>(m[i]);
} else {
for (size_t i = 0, o = 0; i < m.size(); i += 2, o += 3) {
f.data[o] = static_cast<uint8_t>(m[i] & 0xFF);
f.data[o + 1] = static_cast<uint8_t>(((m[i] >> 8) & 0x0F) | ((m[i + 1] & 0x0F) << 4));
f.data[o + 2] = static_cast<uint8_t>(m[i + 1] >> 4);
}
}
return f;
}
bool waitSaved(const ImagePipeline& p, long long n, int ms = 20000) {
const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms);
while (p.imagesSaved() < n && std::chrono::steady_clock::now() < end)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
return p.imagesSaved() >= n;
}
// Mean of channel c over the interior (de-Bayering blurs the frame's edge).
double interiorMean(const fgc_test::Decoded& d, int w, int h, int c) {
double s = 0;
size_t n = 0;
for (int y = 4; y < h - 4; ++y)
for (int x = 4; x < w - 4; ++x, ++n) s += d.px[(static_cast<size_t>(y) * w + x) * 3 + c];
return s / n;
}
ImagePipeline::Params params(const TempDir& t, double distance = 0.0) {
ImagePipeline::Params p;
p.output_dir = t.path.string();
p.tower = "test";
p.cameras = {{"RGB", 0, distance, 1, true}};
p.jxl_threads = 2;
return p;
}
std::string onlyFile(const TempDir& t) {
for (auto& e : fs::recursive_directory_iterator(t.path))
if (e.path().extension() == ".jxl") return e.path().string();
return {};
}
} // namespace
TEST_CASE("a 12-bit raw frame is saved as a 12-bit colour file, colours in place") {
TempDir t;
FakeChannel ch;
ImagePipeline pipe(ch, [] { return Orientation{12.5f, 3.0f}; }, params(t));
pipe.start();
// Red 3200, green 1600, blue 800 of 4095.
pipe.submit(bayerFrame(PixelLayout::BayerRG12p, 64, 48, 3200, 1600, 800, 1000));
REQUIRE(waitSaved(pipe, 1));
pipe.stop();
const auto d = decode(onlyFile(t), 3);
CHECK(d.bits == 12);
REQUIRE(d.px.size() == 64u * 48 * 3);
CHECK(interiorMean(d, 64, 48, 0) == doctest::Approx(3200).epsilon(0.01));
CHECK(interiorMean(d, 64, 48, 1) == doctest::Approx(1600).epsilon(0.01));
CHECK(interiorMean(d, 64, 48, 2) == doctest::Approx(800).epsilon(0.01));
REQUIRE(ch.count() == 1);
CHECK(ch.events[0].heading_decideg == 125);
CHECK(ch.events[0].timestamp_ms == 1000);
}
TEST_CASE("an 8-bit raw frame is saved as 8-bit colour, either de-Bayer method") {
for (bool ea : {true, false}) {
CAPTURE(ea);
TempDir t;
FakeChannel ch;
auto p = params(t);
p.cameras[0].edge_aware = ea;
ImagePipeline pipe(ch, nullptr, p);
pipe.start();
pipe.submit(bayerFrame(PixelLayout::BayerRG8, 64, 48, 200, 100, 50, 2000));
REQUIRE(waitSaved(pipe, 1));
pipe.stop();
const auto d = decode(onlyFile(t), 3);
CHECK(d.bits == 8);
CHECK(interiorMean(d, 64, 48, 0) == doctest::Approx(200).epsilon(0.02));
CHECK(interiorMean(d, 64, 48, 2) == doctest::Approx(50).epsilon(0.05));
}
}
TEST_CASE("RGB8 frames are saved as before") {
TempDir t;
FakeChannel ch;
ImagePipeline pipe(ch, nullptr, params(t, 0.8));
MockCameraSource cam(1, 64, 48, PixelLayout::RGB8);
cam.setFrameCallback([&pipe](const Frame& f) { pipe.submit(f); });
pipe.start();
REQUIRE(cam.trigger());
REQUIRE(waitSaved(pipe, 1));
pipe.stop();
const auto d = decode(onlyFile(t), 3);
CHECK(d.bits == 8);
CHECK(d.px.size() == 64u * 48 * 3);
}
TEST_CASE("rotation applies to raw frames too") {
TempDir t;
FakeChannel ch;
auto p = params(t);
p.cameras[0].rotation_deg = 90;
ImagePipeline pipe(ch, nullptr, p);
pipe.start();
pipe.submit(bayerFrame(PixelLayout::BayerRG12p, 64, 48, 3200, 1600, 800, 3000));
REQUIRE(waitSaved(pipe, 1));
pipe.stop();
const auto d = decode(onlyFile(t), 3);
CHECK(d.px.size() == 48u * 64 * 3); // 48 wide, 64 high now
CHECK(interiorMean(d, 48, 64, 0) == doctest::Approx(3200).epsilon(0.01));
}
TEST_CASE("several workers save every frame") {
TempDir t;
FakeChannel ch;
auto p = params(t, 1.0);
p.workers = 3;
p.jxl_threads = 1;
ImagePipeline pipe(ch, nullptr, p);
pipe.start();
for (int i = 0; i < 9; ++i)
pipe.submit(bayerFrame(PixelLayout::BayerRG12p, 64, 48, 3000, 1500, 700, 5000 + i));
REQUIRE(waitSaved(pipe, 9));
pipe.stop();
CHECK(ch.count() == 9);
size_t files = 0;
for (auto& e : fs::recursive_directory_iterator(t.path)) files += e.path().extension() == ".jxl";
CHECK(files == 9);
}
TEST_CASE("canAccept says no once queue_max frames are waiting, yes once they are saved") {
TempDir t;
FakeChannel ch;
auto p = params(t, 1.0);
p.queue_max = 2;
ImagePipeline pipe(ch, nullptr, p);
CHECK(pipe.canAccept());
pipe.submit(bayerFrame(PixelLayout::BayerRG8, 32, 32, 100, 100, 100, 1));
CHECK(pipe.canAccept());
pipe.submit(bayerFrame(PixelLayout::BayerRG8, 32, 32, 100, 100, 100, 2));
CHECK_FALSE(pipe.canAccept()); // not started: both still waiting
CHECK(pipe.pending() == 2);
pipe.submit(bayerFrame(PixelLayout::BayerRG8, 32, 32, 100, 100, 100, 3)); // never refused
CHECK(pipe.pending() == 3);
pipe.start();
REQUIRE(waitSaved(pipe, 3));
pipe.stop();
CHECK(pipe.canAccept());
CHECK(pipe.pending() == 0);
}
TEST_CASE("a frame shorter than its layout is dropped, not saved") {
TempDir t;
FakeChannel ch;
ImagePipeline pipe(ch, nullptr, params(t));
pipe.start();
Frame f = bayerFrame(PixelLayout::BayerRG12p, 64, 48, 1, 1, 1, 7);
f.data.resize(f.data.size() / 2);
pipe.submit(f);
pipe.submit(bayerFrame(PixelLayout::BayerRG12p, 64, 48, 1, 1, 1, 8));
REQUIRE(waitSaved(pipe, 1));
pipe.stop();
CHECK(pipe.imagesSaved() == 1);
CHECK(ch.count() == 1);
}

140
tests/test_jpegxl.cpp Normal file
View File

@ -0,0 +1,140 @@
#include "fgc/JpegXlEncoder.h"
#include <doctest/doctest.h>
#include "jxl_test_decode.h"
#include <chrono>
#include <cmath>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <random>
#include <string>
#include <vector>
using fgc::JpegXlEncoder;
using fgc_test::decode;
namespace fs = std::filesystem;
namespace {
struct TempDir {
fs::path path;
TempDir() {
path = fs::temp_directory_path() /
("fgc_jxl_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
fs::create_directories(path);
}
~TempDir() { fs::remove_all(path); }
std::string file(const std::string& name) const { return (path / name).string(); }
};
// A test image: smooth gradients (what lossy coding keeps well) plus noise
// and a few hard edges, in `maxv` levels.
std::vector<uint16_t> image(int w, int h, int ch, int maxv, unsigned seed = 1) {
std::mt19937 rng(seed);
std::normal_distribution<> noise(0.0, maxv / 200.0);
std::vector<uint16_t> v(static_cast<size_t>(w) * h * ch);
for (int y = 0; y < h; ++y)
for (int x = 0; x < w; ++x)
for (int c = 0; c < ch; ++c) {
double base = maxv * (0.15 + 0.7 * (x + y * (c + 1)) / double(w + h * ch));
if ((x / 16 + y / 16) % 7 == 0) base = maxv * 0.9; // edges
double val = std::round(base + noise(rng));
v[(static_cast<size_t>(y) * w + x) * ch + c] =
static_cast<uint16_t>(std::min<double>(maxv, std::max(0.0, val)));
}
// The extremes must survive too.
v.front() = 0;
v.back() = static_cast<uint16_t>(maxv);
return v;
}
std::vector<uint8_t> to8(const std::vector<uint16_t>& v) {
return std::vector<uint8_t>(v.begin(), v.end());
}
double meanAbsError(const std::vector<uint16_t>& a, const std::vector<uint16_t>& b) {
double s = 0;
for (size_t i = 0; i < a.size(); ++i) s += std::abs(double(a[i]) - double(b[i]));
return s / a.size();
}
} // namespace
TEST_CASE("8-bit lossless decodes exactly (known-issues #20)") {
TempDir t;
for (int ch : {1, 3}) {
CAPTURE(ch);
const auto src = image(96, 64, ch, 255);
const auto b = to8(src);
REQUIRE(JpegXlEncoder::encodeToFile(t.file("ll8.jxl"), b.data(), 96, 64, ch, 0.0f, 1, 2));
const auto d = decode(t.file("ll8.jxl"), ch);
CHECK(d.bits == 8);
CHECK(d.original_profile);
CHECK(d.px == src);
}
}
TEST_CASE("12-bit lossless decodes exactly, extremes included") {
TempDir t;
for (int ch : {1, 3}) {
CAPTURE(ch);
const auto src = image(96, 64, ch, 4095, 7);
REQUIRE(JpegXlEncoder::encodeToFile(t.file("ll12.jxl"), src.data(), 96, 64, ch, 12, 0.0f,
1, 2));
const auto d = decode(t.file("ll12.jxl"), ch);
CHECK(d.bits == 12);
CHECK(d.channels == ch);
CHECK(d.px == src);
}
}
TEST_CASE("lossy keeps the bit depth and stays close") {
TempDir t;
SUBCASE("12-bit colour") {
const auto src = image(128, 96, 3, 4095, 3);
REQUIRE(JpegXlEncoder::encodeToFile(t.file("l12.jxl"), src.data(), 128, 96, 3, 12, 1.0f,
3, 2));
const auto d = decode(t.file("l12.jxl"), 3);
CHECK(d.bits == 12);
CHECK_FALSE(d.original_profile); // lossy goes through XYB, as before
REQUIRE(d.px.size() == src.size());
CHECK(meanAbsError(d.px, src) < 4095 * 0.02);
}
SUBCASE("8-bit colour, as the program has always saved") {
const auto src = image(128, 96, 3, 255, 3);
const auto b = to8(src);
REQUIRE(JpegXlEncoder::encodeToFile(t.file("l8.jxl"), b.data(), 128, 96, 3, 0.8f, 4));
const auto d = decode(t.file("l8.jxl"), 3);
CHECK(d.bits == 8);
REQUIRE(d.px.size() == src.size());
CHECK(meanAbsError(d.px, src) < 255 * 0.02);
}
}
TEST_CASE("the thread count changes speed, not the file") {
TempDir t;
const auto src = image(160, 120, 3, 4095, 5);
REQUIRE(JpegXlEncoder::encodeToFile(t.file("t0.jxl"), src.data(), 160, 120, 3, 12, 1.0f, 3, 0));
REQUIRE(JpegXlEncoder::encodeToFile(t.file("t4.jxl"), src.data(), 160, 120, 3, 12, 1.0f, 3, 4));
std::ifstream a(t.file("t0.jxl"), std::ios::binary), b(t.file("t4.jxl"), std::ios::binary);
std::vector<char> fa{std::istreambuf_iterator<char>(a), {}}, fb{std::istreambuf_iterator<char>(b), {}};
CHECK(!fa.empty());
CHECK(fa == fb);
}
TEST_CASE("bad input is refused and leaves no file") {
TempDir t;
const auto src = image(16, 16, 3, 255);
const auto b = to8(src);
const std::string p = t.file("bad.jxl");
CHECK_FALSE(JpegXlEncoder::encodeToFile(p, b.data(), 16, 16, 2, 8, 1.0f, 3, 1)); // 2 channels
CHECK_FALSE(JpegXlEncoder::encodeToFile(p, b.data(), 16, 16, 3, 7, 1.0f, 3, 1)); // 7 bits
CHECK_FALSE(JpegXlEncoder::encodeToFile(p, b.data(), 16, 16, 3, 17, 1.0f, 3, 1)); // 17 bits
CHECK_FALSE(JpegXlEncoder::encodeToFile(p, b.data(), 0, 16, 3, 8, 1.0f, 3, 1)); // no width
CHECK_FALSE(JpegXlEncoder::encodeToFile(p, b.data(), 16, 16, 3, 8, -1.0f, 3, 1)); // distance
CHECK_FALSE(JpegXlEncoder::encodeToFile(p, nullptr, 16, 16, 3, 8, 1.0f, 3, 1)); // no image
CHECK_FALSE(fs::exists(p));
}

133
tests/test_rawimage.cpp Normal file
View File

@ -0,0 +1,133 @@
#include "fgc/RawImage.h"
#include <doctest/doctest.h>
#include <random>
#include <vector>
using namespace fgc;
namespace {
// Pack 12-bit values the way the camera does (GenICam 12p).
std::vector<uint8_t> pack12p(const std::vector<uint16_t>& v) {
std::vector<uint8_t> out((v.size() * 3 + 1) / 2);
for (size_t i = 0, o = 0; i < v.size(); i += 2, o += 3) {
const uint16_t p0 = v[i];
const uint16_t p1 = i + 1 < v.size() ? v[i + 1] : 0;
out[o] = static_cast<uint8_t>(p0 & 0xFF);
out[o + 1] = static_cast<uint8_t>(((p0 >> 8) & 0x0F) | ((p1 & 0x0F) << 4));
if (o + 2 < out.size()) out[o + 2] = static_cast<uint8_t>(p1 >> 4);
}
return out;
}
} // namespace
TEST_CASE("layout names round-trip, and unknown formats are refused") {
for (PixelLayout l : {PixelLayout::Mono8, PixelLayout::RGB8, PixelLayout::BayerRG8,
PixelLayout::BayerRG12p})
CHECK(layoutFromName(layoutName(l)) == l);
CHECK(layoutFromName("BayerRG12") == PixelLayout::Unknown); // unpacked: not handled
CHECK(layoutFromName("YCbCr422_8_CbYCrY") == PixelLayout::Unknown);
CHECK(std::string(layoutName(PixelLayout::Unknown)) == "Unknown");
}
TEST_CASE("layout properties") {
CHECK(layoutIsBayer(PixelLayout::BayerRG8));
CHECK(layoutIsBayer(PixelLayout::BayerRG12p));
CHECK_FALSE(layoutIsBayer(PixelLayout::RGB8));
CHECK(layoutBits(PixelLayout::BayerRG12p) == 12);
CHECK(layoutBits(PixelLayout::RGB8) == 8);
CHECK(layoutBits(PixelLayout::Unknown) == 0);
// The 1620c's full frame, as measured: 24.3 / 16.2 / 48.6 MB.
CHECK(layoutFrameBytes(PixelLayout::BayerRG12p, 5328, 3040) == 24295680);
CHECK(layoutFrameBytes(PixelLayout::BayerRG8, 5328, 3040) == 16197120);
CHECK(layoutFrameBytes(PixelLayout::RGB8, 5328, 3040) == 48591360);
CHECK(layoutFrameBytes(PixelLayout::BayerRG12p, 3, 1) == 5); // odd pixel count rounds up
}
TEST_CASE("unpack12p matches GenICam 12p, by hand") {
// p0 = 0xABC, p1 = 0x123: bytes BC, 3A, 12.
const uint8_t in[3] = {0xBC, 0x3A, 0x12};
uint16_t out[2] = {};
unpack12p(in, out, 2);
CHECK(out[0] == 0xABC);
CHECK(out[1] == 0x123);
}
TEST_CASE("unpack12p round-trips every value, odd counts included") {
std::mt19937 rng(12);
std::vector<uint16_t> src(1001);
for (auto& v : src) v = static_cast<uint16_t>(rng() % 4096);
src[0] = 0;
src[1] = 4095;
for (size_t n : {size_t(1000), size_t(1001)}) {
CAPTURE(n);
std::vector<uint16_t> part(src.begin(), src.begin() + n);
const auto packed = pack12p(part);
std::vector<uint16_t> out(n, 0xFFFF);
unpack12p(packed.data(), out.data(), n);
CHECK(out == part);
}
}
TEST_CASE("brightness sample: 8-bit layouts") {
const uint32_t w = 400, h = 300;
SUBCASE("mid grey") {
std::vector<uint8_t> f(w * h, 100);
auto s = sampleBrightness(f.data(), f.size(), PixelLayout::BayerRG8, w, h);
REQUIRE(s.valid());
CHECK(s.mean == doctest::Approx(100));
CHECK(s.clipped == 0);
}
SUBCASE("half blown out, RGB8 mixes the channels") {
std::vector<uint8_t> f(w * h * 3, 20);
for (size_t i = 0; i < f.size() / 2; ++i) f[i] = 255;
auto s = sampleBrightness(f.data(), f.size(), PixelLayout::RGB8, w, h);
REQUIRE(s.valid());
CHECK(s.clipped == doctest::Approx(0.5).epsilon(0.02));
CHECK(s.mean == doctest::Approx((255 + 20) / 2.0).epsilon(0.02));
}
}
TEST_CASE("brightness sample: 12-bit is reported on the 0..255 scale") {
const uint32_t w = 400, h = 300;
std::vector<uint16_t> v(w * h, 1600); // 1600 / 16 = 100
auto f = pack12p(v);
auto s = sampleBrightness(f.data(), f.size(), PixelLayout::BayerRG12p, w, h);
REQUIRE(s.valid());
CHECK(s.mean == doctest::Approx(100));
CHECK(s.clipped == 0);
std::fill(v.begin(), v.end(), 4095);
f = pack12p(v);
s = sampleBrightness(f.data(), f.size(), PixelLayout::BayerRG12p, w, h);
CHECK(s.mean == doctest::Approx(4095 / 16.0));
CHECK(s.clipped == 1);
}
TEST_CASE("brightness sample refuses what it cannot read") {
std::vector<uint8_t> f(100, 50);
CHECK_FALSE(sampleBrightness(f.data(), f.size(), PixelLayout::Unknown, 10, 10).valid());
CHECK_FALSE(sampleBrightness(f.data(), f.size(), PixelLayout::RGB8, 10, 10).valid()); // too short
CHECK_FALSE(sampleBrightness(nullptr, 0, PixelLayout::Mono8, 10, 10).valid());
CHECK(sampleBrightness(f.data(), f.size(), PixelLayout::Mono8, 10, 10).valid());
}
#include "fgc/mock/MockCameraSource.h"
TEST_CASE("the mock camera delivers raw frames like a raw-capture profile") {
for (PixelLayout l : {PixelLayout::RGB8, PixelLayout::BayerRG8, PixelLayout::BayerRG12p}) {
CAPTURE(layoutName(l));
MockCameraSource cam(1, 64, 48, l);
Frame got;
cam.setFrameCallback([&got](const Frame& f) { got = f; });
REQUIRE(cam.trigger());
CHECK(got.layout == l);
CHECK(got.channels == (l == PixelLayout::RGB8 ? 3 : 1));
CHECK(got.data.size() == layoutFrameBytes(l, 64, 48));
REQUIRE(got.brightness.valid());
// The scene's mid grey (110) whatever the layout and bit depth.
CHECK(got.brightness.mean == doctest::Approx(110).epsilon(0.15));
}
}

View File

@ -27,6 +27,18 @@ struct FakeMotor : IMotorController {
struct FakeCamera : ICameraSource {
int triggers = 0;
bool ok = true;
// A stream of frames for the keep rule; empty = a camera with no frame data.
std::vector<FrameStats> stream;
void deliver(double brightness) {
stream.push_back(FrameStats{stream.size() + 1, brightness, 20000, 15});
}
uint64_t frameSeq() override { return stream.empty() ? 0 : stream.back().seq; }
std::vector<FrameStats> framesSince(uint64_t after) override {
std::vector<FrameStats> out;
for (const auto& f : stream)
if (f.seq > after) out.push_back(f);
return out;
}
void open() override {}
void close() override {}
void start() override {}
@ -575,3 +587,130 @@ TEST_CASE("a snapshot during continuous capture fires once, then capture carries
clock = 1200; sch.tick();
CHECK(cam.triggers == 2);
}
TEST_CASE("a scan holds at its waypoint while saving is behind, and loses nothing") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0 /*1000 ms*/, testGeometry(), grid,
[&] { return clock; });
sch.setSettleWait(0, 0);
bool room = true;
sch.setSaveGate([&room] { return room; });
sch.setMode(Mode::Auto);
clock = 1100; sch.tick(); // first move
REQUIRE(motor.cmds.size() == 1);
settleAt(motor, 0, 0);
clock = 1200; sch.tick(); // standstill seen
clock = 1300; sch.tick(); // keep the frame
CHECK(cam.triggers == 1);
room = false; // saving falls behind
clock = 2500; sch.tick(); // interval over, but no room
clock = 9000; sch.tick();
CHECK(motor.cmds.size() == 1); // held: no next move
CHECK(sch.holdingForSave());
room = true; // caught up
clock = 9100; sch.tick();
REQUIRE(motor.cmds.size() == 2); // moves on to the waypoint it was due
CHECK(motor.cmds[1] == "MOVE 900,0");
CHECK_FALSE(sch.holdingForSave());
}
TEST_CASE("continuous capture holds while saving is behind; a snapshot never does") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0 /*1000 ms*/, testGeometry(), grid,
[&] { return clock; });
sch.setSettleWait(0, 2000);
settleAt(motor, 0, 0);
bool room = false;
sch.setSaveGate([&room] { return room; });
sch.requestSnapshot(); // the operator's own picture
clock = 100; sch.tick(); // standstill seen
clock = 200; sch.tick();
CHECK(cam.triggers == 1); // taken despite the full queue
sch.setContinuous(true);
clock = 1500; sch.tick();
clock = 3000; sch.tick();
CHECK(cam.triggers == 1); // held
CHECK(sch.holdingForSave());
room = true;
clock = 3100; sch.tick(); // standstill seen
clock = 3200; sch.tick(); // kept
CHECK(cam.triggers == 2);
}
namespace {
// One waypoint of an auto scan, up to the point where the gimbal has stopped.
struct RuleRig {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid{{{0.0, 0.0}, {90.0, 0.0}}};
CaptureScheduler sch{motor, cam, 1.0, testGeometry(), grid, [this] { return clock; }};
RuleRig() {
sch.setSettleWait(200, 3000);
sch.setSettleRule(true);
cam.deliver(255); // the stream is running
sch.setMode(Mode::Auto);
clock = 1100; sch.tick(); // MOVE
settleAt(motor, 0, 0);
clock = 1200; sch.tick(); // standstill seen
clock = 1400; sch.tick(); // mechanical settle over: rule starts
}
};
} // namespace
TEST_CASE("with the keep rule, the frame is kept once brightness has settled") {
RuleRig r;
for (double b : {230.0, 190.0, 150.0}) { // auto-exposure still coming down
r.cam.deliver(b);
r.clock += 100; r.sch.tick();
}
CHECK(r.cam.triggers == 0);
for (double b : {126.0, 125.0}) { // in the band, steadying
r.cam.deliver(b);
r.clock += 100; r.sch.tick();
}
CHECK(r.cam.triggers == 0); // two frames are not enough
r.cam.deliver(125.5);
r.clock += 100; r.sch.tick();
CHECK(r.cam.triggers == 1);
CHECK_FALSE(r.sch.moving()); // waypoint done
}
TEST_CASE("with the keep rule but no frame data, the fixed wait applies") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam; // no stream
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(200, 3000);
sch.setSettleRule(true);
sch.setMode(Mode::Auto);
clock = 1100; sch.tick();
settleAt(motor, 0, 0);
clock = 1200; sch.tick();
clock = 1400; sch.tick(); // settle wait over: keep at once
CHECK(cam.triggers == 1);
}
TEST_CASE("with the keep rule, an image that never settles is kept at the time limit") {
RuleRig r;
for (int i = 0; i < 25; ++i) { // stuck far too bright, mid-way pause
r.cam.deliver(157);
r.clock += 100; r.sch.tick();
}
CHECK(r.cam.triggers == 0); // 1400 + 2500 < 1200 + 200 + 3000
r.cam.deliver(157);
r.clock = 4500; r.sch.tick(); // past settle + timeout
CHECK(r.cam.triggers == 1);
}

138
tests/test_settlerule.cpp Normal file
View File

@ -0,0 +1,138 @@
#include "fgc/SettleRule.h"
#include <doctest/doctest.h>
#include <algorithm>
#include <cmath>
#include <fstream>
#include <map>
#include <sstream>
#include <string>
#include <vector>
using namespace fgc;
namespace {
FrameStats frame(double b, double e = 20000, double g = 15) {
static uint64_t seq = 0;
return FrameStats{++seq, b, e, g};
}
struct Row {
double t, exposure, gain, brightness;
};
// The 24 auto-exposure steps recorded on towers 121 and 111 (tests/data/ae_steps.csv).
std::map<int, std::vector<Row>> recordedSteps() {
std::map<int, std::vector<Row>> runs;
std::ifstream f(std::string(FGC_SOURCE_DIR) + "/tests/data/ae_steps.csv");
std::string line;
while (std::getline(f, line)) {
if (line.empty() || line[0] == '#') continue;
std::stringstream ss(line);
std::string field;
std::vector<std::string> v;
while (std::getline(ss, field, ',')) v.push_back(field);
if (v.size() != 8) continue;
runs[std::stoi(v[0])].push_back({std::stod(v[4]), std::stod(v[5]), std::stod(v[6]),
std::stod(v[7])});
}
return runs;
}
} // namespace
TEST_CASE("settled: in the camera's band and steady for 3 frames") {
SettleRule r; // band 50 +- 5 % of 255 = 114.75 .. 140.25
CHECK(r.add(frame(120)) == SettleRule::Verdict::Waiting);
CHECK(r.add(frame(121)) == SettleRule::Verdict::Waiting);
CHECK(r.add(frame(121.5)) == SettleRule::Verdict::Settled);
}
TEST_CASE("not settled while brightness is still moving through the band") {
SettleRule r;
r.add(frame(139));
r.add(frame(131)); // -6%: still coming down
CHECK(r.add(frame(124)) == SettleRule::Verdict::Waiting);
CHECK(r.add(frame(122)) == SettleRule::Verdict::Waiting); // 124 -> 122 steady, 131 -> 124 not
CHECK(r.add(frame(122)) == SettleRule::Verdict::Settled);
}
TEST_CASE("a pause outside the band is not settled (auto-exposure stalls mid-way)") {
SettleRule r;
for (int i = 0; i < 10; ++i) CHECK(r.add(frame(157, 20000, 19.6)) == SettleRule::Verdict::Waiting);
}
TEST_CASE("steady but too dark at full exposure and gain: waiting cannot help") {
SettleRule::Params p;
p.exposure_max_us = 20000;
p.gain_max_db = 24;
SettleRule r(p);
r.add(frame(40, 20000, 24));
r.add(frame(40.2, 20000, 24));
CHECK(r.add(frame(40.1, 20000, 24)) == SettleRule::Verdict::AtLimit);
// Below the limits it is just slow, not stuck.
SettleRule s(p);
s.add(frame(40, 20000, 12));
s.add(frame(40, 20000, 12));
CHECK(s.add(frame(40, 20000, 12)) == SettleRule::Verdict::Waiting);
}
TEST_CASE("steady but too bright at minimum exposure and gain") {
SettleRule::Params p;
p.exposure_min_us = 82;
p.gain_min_db = 0;
SettleRule r(p);
r.add(frame(200, 82, 0));
r.add(frame(200, 82, 0));
CHECK(r.add(frame(200, 82, 0)) == SettleRule::Verdict::AtLimit);
}
TEST_CASE("a frame without brightness starts the count again") {
SettleRule r;
r.add(frame(120));
r.add(frame(120));
CHECK(r.add(frame(-1)) == SettleRule::Verdict::Waiting);
CHECK(r.add(frame(120)) == SettleRule::Verdict::Waiting);
CHECK(r.add(frame(120)) == SettleRule::Verdict::Waiting);
CHECK(r.add(frame(120)) == SettleRule::Verdict::Settled);
}
TEST_CASE("the band follows the camera's target and tolerance") {
SettleRule::Params p;
p.target_pct = 40;
p.tolerance_pct = 10;
SettleRule r(p);
CHECK(r.bandLow() == doctest::Approx(76.5));
CHECK(r.bandHigh() == doctest::Approx(127.5));
}
TEST_CASE("replayed on the 24 recorded exposure steps, it keeps a good frame at least 21 times") {
const auto runs = recordedSteps();
REQUIRE(runs.size() == 24);
int correct = 0, early = 0, never = 0;
for (const auto& [id, rows] : runs) {
// Where the run finally settles: median brightness over its last 2 s.
std::vector<double> tail;
for (const auto& r : rows)
if (r.t >= rows.back().t - 2.0) tail.push_back(r.brightness);
std::sort(tail.begin(), tail.end());
const double final_level = tail[tail.size() / 2];
SettleRule rule;
bool fired = false;
for (const auto& r : rows) {
if (rule.add(FrameStats{0, r.brightness, r.exposure, r.gain}) !=
SettleRule::Verdict::Waiting) {
fired = true;
(std::fabs(r.brightness / final_level - 1.0) <= 0.10 ? correct : early)++;
break;
}
}
never += !fired;
}
INFO("correct " << correct << ", early " << early << ", never " << never);
CHECK(correct >= 21);
CHECK(never == 0);
}