Compare commits

...

8 Commits

47 changed files with 7071 additions and 773 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

@ -130,8 +130,9 @@ flowchart TD
A([next waypoint]) --> B["move yaw and pitch<br/>to the waypoint"]
B --> C{"both axes<br/>stopped?"}
C -- no --> C
C -- yes --> D["hold still for settle_wait_ms<br/>(1 s by day, 2.5 s at night)"]
D --> E["keep the newest frame<br/>the camera delivered"]
C -- yes --> D["hold still for settle_wait_ms<br/>(and, with settle_rule = brightness,<br/>until the brightness has settled)"]
D --> W["wait out what is left of<br/>image_interval since the last picture"]
W --> E["keep the newest frame<br/>the camera delivered"]
E --> F["encode to JPEG XL<br/>save to the output folder"]
F --> G["publish a CamEvent<br/>(camera, heading, pitch, time)"]
G --> A

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

@ -14,7 +14,7 @@
[General]
; Tower identity, substituted into all MQTT topics and CamEvent payloads.
tower_name = ExampleTower
; Seconds between captures.
; Desired seconds from one picture to the next (decimals allowed, e.g. 2.5).
image_interval = 5
; 1 = print motor telemetry each loop tick, 0 = quiet.
debug = 0
@ -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

@ -79,8 +79,10 @@ corrupted.
pan's pitch, converted to counts via `Geometry`.
- manual: nothing, bar a requested snapshot or continuous capture (`capture on`), which skip the
move and wait only for standstill.
- Once **both axes report standstill at the target**, wait `settle_wait_ms`, then keep the newest
frame and advance the grid or pan position.
- Once **both axes report standstill at the target**, wait `settle_wait_ms` (and, with
`settle_rule = brightness`, until the brightness has settled), then whatever is left of
`image_interval` since the last frame kept; keep the newest frame and move straight on to the next
grid or pan position - unless saving is behind, when the scan holds until it catches up.
5. **Acquisition** — the camera free-runs at the active profile's `AcquisitionFrameRate` with its own
auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image
capture* below.
@ -94,20 +96,29 @@ corrupted.
## Capture state machine
```
interval elapsed AND mode auto or pan AND not already moving
mode auto or pan AND not already moving (straight after the last frame)
AND room to save (else hold here until saving catches up)
│
▼
┌──────────────────────┐ auto → next ScanGrid (yaw,pitch)
│ MOVE <yaw>,<pitch> │ pan → next pan heading, at the pan's pitch
│ (yaw-only: MOVE Y) │ deg→counts via Geometry; set moving, reset timer
┌──────────────────────┐ auto → next ScanGrid (yaw,pitch); pan → next pan heading
│ MOVE <yaw>,<pitch> │ unreachable targets skipped in the same tick
│ (yaw-only: MOVE Y) │ deg→counts via Geometry; set moving
└──────────┬───────────┘
│ (both axes standstill AND |xenc − target| ≤ tol)
▼
┌──────────────────────┐ the camera keeps streaming throughout; this pause is
│ wait settle_wait_ms │ what makes the kept frame one exposed at rest
│ wait settle_wait_ms, │ what makes the kept frame one exposed at rest.
│ then the keep rule │ settle_rule = brightness: until brightness is in band
│ if on │ and steady, at most frame_timeout_ms (then keep, warn)
└──────────┬───────────┘ move + wait > interval: warn (not on the return to the
│ first waypoint, nor the first move after a mode starts)
▼
┌──────────────────────┐
│ wait out the rest of │ interval counted from the last frame kept
│ image_interval │
└──────────┬───────────┘
▼
┌──────────────────────┐ on success: clear moving, advance the grid / pan position
┌──────────────────────┐ on success: clear moving, advance, restart the interval
│ camera.trigger() │ no fresh frame: retry next tick
└──────────────────────┘ still none after frame_timeout_ms: warn, skip, advance
```
@ -132,15 +143,27 @@ 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. with `settle_rule = brightness`, waits on until brightness is in the camera's
target band and steady for 3 frames
([configuration.md](configuration.md#which-frame-a-waypoint-keeps-settle_rule));
4. waits out what is left of `image_interval`, counted from the last frame kept, and
keeps the newest complete frame.
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
floor on a profile's frame rate: a profile must produce comfortably more than one frame inside its own settle wait,
which is why `night` at 1 Hz waits 2.5 s where `standard` at 10 Hz waits 1 s.
If no fresh frame has arrived by `frame_timeout_ms` past the wait, the waypoint is logged and skipped. A
stalled camera costs single images, never the whole sweep.
If no fresh frame arrives within `frame_timeout_ms` of the frame falling due, the waypoint is logged and
skipped. A stalled camera costs single images, never the whole sweep. A merely slow camera is not stalled:
with multi-second exposures the next frame may not be due yet, so the stream is only restarted once nothing
newer has come for twice the longer of the exposure and the frame period, plus a second (at least 2 s;
`CameraStall.h`). Restarting earlier throws the exposure in progress away.
### Camera profiles

View File

@ -27,7 +27,7 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| Section | Key | Type | Default | Meaning |
|---------|-----|------|---------|---------|
| `General` | `tower_name` | string | `Unnamed` | Tower identity; used in all MQTT topics/payloads |
| `General` | `image_interval` | int > 0 | `5` | Seconds between captures (→ `image_rate = 1/interval`) |
| `General` | `image_interval` | number > 0 | `5` | Desired seconds from one picture to the next, decimals allowed (→ `image_rate = 1/interval`). Counted from the last frame kept; the move and the exposure wait come out of it. A waypoint whose move + wait outlasts it warns (not the return to the first waypoint, nor the first move after a mode starts) |
| `General` | `debug` | bool | `false` | Start with debug-level logging |
| `Startup` | `home` | bool | `false` | Home the gimbal at every start (see [Startup](#startup--homing-and-the-start-mode)). A tower in service needs `true`, and then homes **unattended** at every boot |
| `Startup` | `mode` | `manual`/`auto` | `manual` | Mode the tower starts in. `auto` is refused, leaving it in manual, if the gimbal is not homed by then. `pan` is by command only |
@ -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` | With `settle_rule = brightness`, the longest wait for the brightness after `settle_wait_ms`; then keep the newest frame anyway. Also how long to wait for a frame once one is due before skipping the waypoint |
| `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,43 @@ 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. The
rule applies to scan and pan waypoints; a snapshot or continuous capture keeps
the fixed wait. Its time counts towards `image_interval`, like the move's.
#### 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. That waypoint does not also warn about running past
`image_interval`. Continuous capture holds the same way; 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
@ -654,7 +696,7 @@ Handled in `Application::Impl::handleCommand`.
| `trace all` / `trace off` | Enable every category / silence all |
| `set camera profile [<camera>] <name>` | Switch a camera to another profile from its `profile_dir` (the camera may be omitted when only one is configured) |
| `snapshot` | One image now, through the scan's settle-and-trigger path (manual mode, homed) |
| `capture [on\|off]` | Continuous capture where the gimbal is aimed: one image per `image_interval` + standstill + `settle_wait_ms`, kept only at rest. Manual mode, homed; ends on any mode change, homing, heading setup or test. Bare: report |
| `capture [on\|off]` | Continuous capture where the gimbal is aimed: one image per `image_interval` since the last, each once the gimbal has stood still for `settle_wait_ms`. Manual mode, homed; ends on any mode change, homing, heading setup or test. Bare: report |
| `camera reset` | Reboot the cameras and put them back on their profiles |
| `test camera [frames\|dark\|bright]` | Check the camera delivers frames and responds to exposure/gain |
| `test [<subsystem> [<leaf>]] [<profile>]` | Run the hardware self-test suite (see below) |

View File

@ -29,10 +29,10 @@ 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)). |
| 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)). **Slow, not stuck, 2026-10-04:** at dusk tower 111 ran multi-second exposures, one frame every 7-9 s, and the program restarted the stream whenever a picture fell due before the next frame had come - throwing that exposure away, so the next frame came later still. A stream that has delivered is now only restarted once nothing newer has come for twice the longer of the exposure and the frame period, plus a second (`CameraStall.h`); not yet checked on the tower. |
| 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

@ -34,7 +34,10 @@ Per-file reference for the refactored tree, plus the shared data structures.
| [include/fgc/ChannelTopics.h](../include/fgc/ChannelTopics.h), [src/core/ChannelTopics.cpp](../src/core/ChannelTopics.cpp) | The MQTT wire format in one place: topic builders for the `GGS/FWT/<tower>/…` tree and the `CamEvent`/status payload builders. Pure, so the documented format is unit-tested rather than assembled inline at each publish site |
| [include/fgc/ChannelHistory.h](../include/fgc/ChannelHistory.h), [src/core/ChannelHistory.cpp](../src/core/ChannelHistory.cpp) | Ring buffer of recently published messages (time, topic, payload, outcome) plus the delivery counters — sent, failed, connects, losses, last status. Feeds the expanded Connectivity view so a publish can be confirmed without a broker-side subscriber |
| [include/fgc/Hash.h](../include/fgc/Hash.h), [src/core/Hash.cpp](../src/core/Hash.cpp) | FNV-1a 64 over a string or a file, with an algorithm-prefixed label. Change detection for the profile records, deliberately not a crypto hash |
| [include/fgc/CaptureScheduler.h](../include/fgc/CaptureScheduler.h), [src/core/CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) | Capture state machine over the interfaces; injectable clock; `setGeometry` adopts a recalibration |
| [include/fgc/CaptureScheduler.h](../include/fgc/CaptureScheduler.h), [src/core/CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) | Capture state machine over the interfaces: move, standstill, settle wait (+ `SettleRule`), the rest of the interval since the last frame, keep; holds while saving is behind; injectable clock; `setGeometry` adopts a recalibration |
| [include/fgc/SettleRule.h](../include/fgc/SettleRule.h), [src/core/SettleRule.cpp](../src/core/SettleRule.cpp) | The `settle_rule = brightness` keep rule, pure: fed one frame's brightness, exposure and gain at a time, says when the image has settled (in the camera's target band and steady for 3 frames) or is pinned at the auto-exposure limits |
| [include/fgc/RawImage.h](../include/fgc/RawImage.h), [src/core/RawImage.cpp](../src/core/RawImage.cpp) | Pixel layouts the program accepts (`Mono8`, `RGB8`, `BayerRG8`, `BayerRG12p`), their sizes and bit depths, `unpack12p`, and `sampleBrightness` - the cheap per-frame sample the keep rule reads |
| [include/fgc/CameraStall.h](../include/fgc/CameraStall.h), [src/core/CameraStall.cpp](../src/core/CameraStall.cpp) | When a camera stream delivers nothing new: wait, restart the stream, or reset the camera. Pure policy, scaled by the exposure and frame period so a slow frame is not taken for a stall |
| [include/fgc/Application.h](../include/fgc/Application.h), [src/core/Application.cpp](../src/core/Application.cpp) | Factory (real vs mock, headless vs TUI), wiring, control loop, `gimbal …` commands, background-result polling, `buildSnapshot()` |
| [include/fgc/ui/UiSnapshot.h](../include/fgc/ui/UiSnapshot.h), [src/ui/UiSnapshot.cpp](../src/ui/UiSnapshot.cpp) | Plain-data view model (incl. `ImuView`, `EnvView`, `ActivityView`, `CalibResultView`, `DumpView`) + pure formatting helpers (state label/colour, degrees, time-ago, pending-sensors) |
| [include/fgc/ui/HeadlessUi.h](../include/fgc/ui/HeadlessUi.h), [src/ui/HeadlessUi.cpp](../src/ui/HeadlessUi.cpp) | Default line console: stdin → command sink; logs via the default stdout/stderr writer |
@ -62,8 +65,8 @@ Per-file reference for the refactored tree, plus the shared data structures.
| [src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp) | `IControlChannel` over Eclipse Paho | `WITH_MQTT` |
| [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp) | `IUserInterface` over FTXUI: panels, key bar, log pane | `WITH_TUI` |
| [src/camera/VimbaCameraSource.cpp](../src/camera/VimbaCameraSource.cpp) | `ICameraSource` over Vimba X (pImpl): free-running stream + keep-latest, restart-on-stall, and per-camera profiles applied with the SDK's `LoadSettings` on the Viewer's settings XML | `WITH_VIMBA` |
| [src/camera/JpegXlEncoder.cpp](../src/camera/JpegXlEncoder.cpp) | libjxl encode-to-file | always |
| [src/camera/ImagePipeline.cpp](../src/camera/ImagePipeline.cpp) | frame → rotate → encode → write → CamEvent (worker thread). The pose is sampled in `submit()` and carried with the frame, so it belongs to the pixels rather than to whenever the encode finished | always |
| [src/camera/JpegXlEncoder.cpp](../src/camera/JpegXlEncoder.cpp) | libjxl encode-to-file, 8-bit or up to 16-bit values (a 12-bit frame keeps its depth); distance 0 is lossless | always |
| [src/camera/ImagePipeline.cpp](../src/camera/ImagePipeline.cpp) | frame → de-Bayer (raw profiles) → rotate → encode → write → CamEvent (`save_workers` background workers; `canAccept()` is the scan's save gate). The pose is sampled in `submit()` and carried with the frame, so it belongs to the pixels rather than to whenever the encode finished | always |
## Mock / null implementations

View File

@ -46,7 +46,7 @@ edges included: 75, 81, 87, 93, 99, 105 - six pictures. A span that does not
divide evenly gets one more picture, never a gap (32° → seven, 5.33° apart). A
span under one picture is the one heading, photographed once per loop. Pitch is
optional and defaults to 0 (level); a yaw-only tower ignores it, with a warning. Pictures are taken at the same pace as auto
(`image_interval`), and each publishes a CamEvent exactly as a sweep picture
(one per `image_interval`, counted from the last picture), and each publishes a CamEvent exactly as a sweep picture
does.
**Headings are true bearings and can be out of reach.** Yaw travel is under a

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
@ -26,7 +51,7 @@ exactly as today.
## Processing budget the design must fit
Measured on the **identical frame** on both computers (5328×3040, real scene; 4 threads; see the
report's [Where the time goes](raw12-pipeline-measurements.md#where-the-time-goes)).
report's [Section 3.5](raw12-pipeline-measurements.md#35-processing-time-per-step)).
### Time per saved frame, step by step

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,193 @@
# Context for discussing `raw12-pipeline-measurements.md`
This file gives an AI assistant the background needed to discuss the report
[raw12-pipeline-measurements.md](raw12-pipeline-measurements.md): what was asked, how the tests were
run, what the numbers mean, what was decided, which points have been questioned, and what is still
open. Read the report itself for the full tables and figures; this file explains around them.
State as of 2026-10-05, branch `raw12-capture`.
## 1. The system in one paragraph
Fire-watch towers scan waypoints with a gimbal-mounted Allied Vision Alvium camera and keep one
picture per waypoint, saved as JPEG XL on the tower's computer. After the gimbal stops, the camera's
continuous auto-exposure needs frames to adapt to the new view, so the scan waits ("settling"). The
tower program is C++ (`fgc`), using Vimba X (VmbCPP), OpenCV and libjxl. Two computers are in use:
LattePanda IOTA (Intel N150, tower 121) and LattePanda 3 Delta (Celeron N5105, tower 111).
## 2. The question and how it evolved
- Origin: speeding up the per-waypoint capture. Today the camera sends RGB8 (colour made in the
camera, 48.6 MB per full frame), which limits the frame rate over USB to 4.95 fps at the agreed
250 MB/s link cap, so exposure settles slowly.
- Proposal tested: camera sends raw `BayerRG12p` (24.3 MB, 9.86 fps), the tower unpacks,
de-Bayers (converts to colour) and saves 12-bit colour JPEG XL.
- An earlier campaign ([camera-capture-measurements.md](camera-capture-measurements.md)) used
blurred dim scenes; its compression numbers were unrepresentative. This campaign used a focused,
real scene (spruce, birch, leaves, building, ~13% clipped sky).
- The report went through several rounds of requested edits: example images per compression type,
methodology detail (how settings were forced), processing on one identical frame on both towers,
an abstract, a pixel-format description, an academic structure (numbered sections, figure and
table captions, units in every header, references), captions that explain every term, an
explanation of raw mosaic vs raw planes, a more careful treatment of what 12 bits add, and a fuller
explanation of the keep rules.
## 3. User decisions (fixed constraints, do not re-litigate)
- Full resolution always; **no binning**; **one quality setting** (no switching between a fast low
quality mode and a slow high quality one); continuous acquisition.
- **No exposure memory** across passes or from neighbouring waypoints.
- USB link cap **250 MB/s** (`DeviceLinkThroughputLimit`), conservative.
- **`BayerRG12p` everywhere**, day and night profiles.
- Save **12-bit colour JPEG XL only**; no raw-planes option in the program for now.
- **Fixed white balance** ratios kept in the profiles (auto white balance was measured but not adopted).
- When saving falls behind, **pause the scan** (save gate) rather than drop frames.
- Writing style for the report: academic structure, captions that explain their terms, units given
wherever possible, plain language. Commit messages: one plain-language line, no trailers.
## 4. Test setup essentials
- Same camera for both towers: Alvium 1800 U-1620c `DEV_1AB22C0B7D4E`, 5328 × 3040, firmware 14.3.
Moved from 121 to 111 so only the computer differs. Tower 111's own camera (1800 U-1240c, 12 MP,
4024 × 3036) was not tested; its frames are 25% smaller, so the Delta results are conservative.
- Dates: tower 121 on 2 Oct 2026 (sunny afternoon); tower 111 on 4 Oct (afternoon into evening);
shared-frame processing on both towers simultaneously on 4 Oct 23:03–23:32.
- The tower program was stopped during all tests.
- Storage write speed: IOTA 218 MB/s, Delta 76 MB/s (eMMC). Libraries: 121 libjxl 0.11.1 /
OpenCV 4.10; 111 libjxl 0.11.0 / OpenCV 4.5.4.
- Tools in `tools/camera-bench/` (camera_bench.py with vmbpy; raw12_bench.cpp; run scripts;
plot_results.py, plot_common.py, make_figures.py, compare_examples.py). Results in
`results-2026-10-02-tower121/`, `results-2026-10-04-tower111/`, `results-common-tower121/111/`.
Full-size example `.jxl` files in `tools/camera-bench/examples-*` (git-ignored, ~115 MB per set).
The recorded auto-exposure steps are also in `tests/data/ae_steps.csv` (used by a unit test).
## 5. Methods that are easy to misunderstand
- **Forcing settings:** feature writes over USB while streaming. `ExposureAuto`/`GainAuto` set to
`Off` freezes current values, then `ExposureTime`/`Gain` are written; for the 2 s "force" phase the
camera delivers 10–30 frames at the forced values; then auto is set back to `Continuous` (time 0).
Dark step: exposure E₀/16 with 0 dB gain. Bright step: exposure at the 20 ms cap plus gain up to
+24.1 dB (×16). Bright start is fully clipped (97–100%), so the camera cannot see how far over it is.
- **Brightness** = mean of a sparse sample of the frame buffer on 0–255 (12-bit: high byte).
**Settled** = stays within ±10% of the median brightness of the run's last 2 s.
- **Frames in transit:** the first 1–5 frames after release still carry the forced values.
- **Shared frame:** tower 121's real frame rebuilt bit-exactly from its lossless 12-bit file and
copied to both towers (identical MD5). Its 8-bit raw version is the 12-bit value >> 4; its "RGB8"
version is the tower's own edge-aware conversion scaled to 8 bits, so on the shared frame the RGB8
file size equals raw 8-bit's (2.66 MB). The camera's real RGB8 files were 3.7–4.0 MB.
- **Synthetic frame:** "dead leaves" texture plus noise (ISO 19567-2 style), sharper than any real
scene; worst case for the encoder; used for pile-up and heat.
- **Quality** is against the exact lossless 12-bit colour image of the same frame: PSNR on the
12-bit scale over 3 channels; SSIM on luma. JPEG XL is perceptual, so PSNR is moderate while SSIM
is high.
- Camera `BayerRG` = OpenCV `COLOR_BayerBG2RGB` (verified). The camera applies white balance to raw.
- 12-bit JPEG XL from 16-bit words needs bit depth "from codestream", or values get rescaled;
lossless needs the original colour profile. An earlier mistake on both points was found and the
12-bit numbers corrected before the final report.
## 6. Key results (numbers to quote)
Frame rate at 250 MB/s (fps): RGB8 4.95, BayerRG8 14.75, BayerRG10p 11.81, BayerRG12p 9.86,
BayerRG12 7.41. 60 s streams: zero lost or incomplete frames in every format on both towers, also
while saving continuously. On 111 the camera shares the USB 3 controller with a 5G modem, a USB
network adapter and the idle second camera.
Auto-exposure settling after ×16 steps: ~25–35 frames, so time scales with 1/frame rate.
BayerRG8 1.8–2.7 s; BayerRG12p 2.2–6.6 s (typically ≈ 3 s; long runs contain mid-way pauses);
RGB8 4.6–6.6 s. With exposure at its 20 ms cap the camera adjusts gain by 1–2 dB per frame and
sometimes pauses up to 2.8 s while still far too bright.
Keep rules (24 runs = 3 formats × 2 directions × 2 repeats × 2 towers; good = within ±10% of
final level):
- "Exposure and gain unchanged 3 frames": 0/24. Replay detail (added 2026-10-05 from
`tests/data/ae_steps.csv`): 16 runs fired on the first frames (0.2–0.5 s, frames in transit, image
black 9–13 or white 253–255 vs ≈ 120); 8 fired during mid-way pauses at brightness 33–227.
- "In band 2 frames": 12/24 (passes through the band ~15% above final level when descending).
- "In band and steady (≤ 2%/frame) 3 frames": 21/24 (3 slightly early on descents); median time
when correct 3.61 s (121) / 5.45 s (111). Implemented as `settle_rule = brightness`.
- Band = `IntensityControllerTarget` ± `Tolerance` = 50 ± 5% → 114.75–140.25 on 0–255.
Auto white balance: recovers in 2–4 frames (0.2–0.8 s), stable ±0.8–3.1%. Exposure, not white
balance, sets the wait. Auto ratios ≈ red 2.36–2.46, blue 1.87–1.97 vs fixed profile 2.87 / 2.0.
Processing on the shared frame (4 threads; IOTA / Delta, ms): unpack 10 / 17; de-Bayer 12-bit
bilinear 17 / 22, edge-aware 28 / 33; encode 12-bit d1.0 e3 480 / 765; today's RGB8 encode
d0.8 e4 566 / 867. Totals: 12-bit edge-aware d1.0 e3 **518 / 820 ms, 2.65 MB**; today RGB8
567 / 872 ms, 3.27 MB; raw 8-bit → colour 468 / 741 ms, 2.66 MB. Delta ≈ 1.6× slower.
Unpack + de-Bayer = 7% of a save; encode ≈ 90%. Effort 1–4 within ~15%; effort 3 smallest;
4 threads fastest; on the IOTA 3 threads is oddly slower than 2.
Distance sweep (12-bit colour, shared frame, MB): lossless 44.8, d0.3 7.19, d0.5 5.15, d0.8 3.16,
d1.0 2.65, d1.5 1.63, d2.0 1.32. Quality at d1.0: PSNR 36.6 dB, SSIM 0.973. Raw planes d0.5:
6.0 MB, PSNR 47.2 dB. Lossless: raw planes 17.6, raw mosaic 20.9, colour 47.4 MB.
Throughput (saves/s back to back, shared frame): 1 worker × 4 threads IOTA 1.79, Delta 1.19;
2 × 2: 2.15 / 1.53. On the shared frame the IOTA keeps up with one frame per 0.7 s with one worker;
the Delta needs two. Pile-up details on the synthetic frame are in Table 18.
Heat (10 min each): one frame every 2 s → IOTA 53 °C mean / 5.1 W, Delta 66 °C / 5.7 W. Back to
back (2 × 2): IOTA 80 °C, 12.5 W, loses 16% throughput as it heats; Delta 78 °C, at its 8 W limit,
steady.
Storage: 12-bit colour d1.0 ≈ 2.65–2.75 MB/frame → 114–119 GB/day at one frame every 2 s. Towers
have 11–22 GB free, so images must leave the tower continuously.
## 7. The 12-bit question (discussed at length; current position)
- Noise floor (12-bit DN, green pixels): at ≈ 700 DN 26 (0 dB) / 42 (15 dB); at ≈ 1500 35 / 58;
at ≈ 3000 37 / 80. One 8-bit step = 16 DN; rounding error RMS = 16/√12 = 4.6 DN.
- Errors add in quadrature: √(26² + 4.6²) = 26.4 → rounding raises noise by at most 1.5% in the
measured daylight scene (0.6% at 15 dB, < 0.2% in bright areas).
- Averaging: while noise > one 8-bit step, rounding error is random (dither) and averages down at
the same √N rate as noise, so 8-bit averages resolve sub-step differences as well as 12-bit. A
simulation (not in the report, by user request) showed a 3 DN difference over 100 × 100 pixels
recovered exactly by both at noise 26, but only 0.2 of 3 by 8-bit at noise 2.
- 12 bits always contain at least as much information; at noise 26 the dropped bits carry about
0.02 bits/pixel of scene information.
- Compression dominates: PSNR 36.6 dB at d1.0 ≈ 61 DN RMS error (13× the rounding error); d0.3
≈ 40 DN; raw planes d0.5 ≈ 18 DN. Much of that is discarded noise, and JPEG XL keeps relatively
more precision in dark tones; shadow-only error was not measured.
- Where 12 bits should matter (Table 17b): dark areas at low gain where noise < one 8-bit step
(below ≈ 300 DN at 0 dB, ≈ 100 DN at 15 dB), i.e. high-contrast scenes such as shaded forest or
backlit ridges under a bright sky at dawn or dusk; thin smoke over dark terrain is the relevant
fire-detection case. At night with high gain, noise is amplified above the step again, so little
benefit. Strong shadow lifting (≥ 3 stops) untested; 2 stops showed no difference.
- None of the dark cases were measured (the darkest area in the scene was ≈ 700 DN; a dusk attempt
on 121 needed 24 dB and was discarded). Proposed test: a dawn/dusk frame with dark terrain under a
bright sky, compared as in Figures 12 and 13.
- 12-bit vs 8-bit raw costs little in saving (same size, ~10% more time) but the camera runs at
9.86 vs 14.75 fps, ~1 s slower settling in daylight. The main gain over today is frame rate, not
bit depth.
## 8. Limitations to keep in mind
One scene, two afternoons, main runs at ≈ 15 dB gain plus one 0 dB frame (124 ms exposure, cap
raised to 200 ms); no night data. Steps forced on a still camera, not real slews; real
waypoint-to-waypoint changes are usually smaller. Tower program stopped, so its own load is not
included. Detection quality not measured (PSNR/SSIM stand in). Slew time (≈ 1–1.5 s) and
mechanical settling (≈ 0.2 s) in the pace table are estimates.
## 9. Implementation status (branch `raw12-capture`)
Implemented and unit-tested (457 tests pass locally): 12-bit JPEG XL encoding with the bit-depth fix
and true lossless (known issue #20 fixed); frames carry pixel layout, chunk exposure/gain/white
balance and a brightness sample; `ImagePipeline` unpacks and de-Bayers (edge-aware default),
N workers, bounded queue; scheduler save gate pauses the scan when the queue is full; `SettleRule`
(`settle_rule = brightness`) with a time limit and an "at limit" exit; config keys `save_workers`,
`jxl_threads`, `save_queue_max`, `demosaic`, `settle_rule`; profile export script recipe for raw
`standard` and `night` profiles. Plan: [raw12-pipeline-implementation-plan.md](raw12-pipeline-implementation-plan.md).
Not done yet: exporting the new camera profiles on a tower (the 1620c is currently on tower 111
and must be moved back to 121 first); field verification with real slews; checking that downstream
consumers decode 12-bit files; the dawn/dusk 12-bit test. The branch is deployed on tower 111,
whose boot service still passes old `--init/--start` flags and needs `scripts/install-tower`.
## 10. Report structure (for orientation)
Abstract; Units and abbreviations; 1 Introduction (1.1 question, 1.2 pixel formats incl. raw
mosaic vs raw planes, Figures 1a/1b, Table 1; 1.3 earlier work); 2 Materials and methods (2.1–2.12,
Tables 2–8, Figure 2); 3 Results (3.1 frame rate, 3.2 auto-exposure, 3.3 keep rules, 3.4 white
balance, 3.5 processing per step, 3.6 effort and threads, 3.7 quality setting, 3.8 example images,
3.9 information content of 12-bit data with Tables 17a/17b, 3.10 throughput, 3.11 heat,
3.12 storage; Figures 3–17, Tables 9–20); 4 Discussion (summary Table 21, pace Table 22,
limitations); 5 Conclusions and six recommendations; References [1]–[7]; Appendix A raw data and
tools; Appendix B per-tower processing times. Figures live in `docs/images/raw12/`.

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

@ -28,6 +28,18 @@ long long firstFrameLimitMs(double exposure_us);
// `failed_restarts` counts restarts since the last frame that arrived.
StallAction onNoFirstFrame(long long waited_ms, double exposure_us, int failed_restarts);
// How long a stream that has delivered may go without a newer frame before it
// counts as stalled: twice the longer of the exposure and the frame period, plus
// a second, and never under 2 s. A slow frame is not a stall - tower 111 runs
// multi-second exposures, one frame every 7-9 s, and restarting the stream the
// moment a capture fell due before the next frame threw that exposure away, so
// the next frame came later still (2026-10-04).
long long staleFrameLimitMs(double exposure_us, double frame_period_ms);
// No frame newer than the last one kept, the newest having arrived
// `since_newest_ms` ago: wait for the next one, or restart the stream.
StallAction onStaleFrame(long long since_newest_ms, double exposure_us, double frame_period_ms);
// A stream that delivered before but has gone stale, and a restart has just
// failed to bring a fresh frame. Restart again, or reset once restarts have
// stopped working.

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>
@ -17,13 +18,22 @@ namespace fgc {
//
// Each tick():
// 1. reads motor telemetry (per-axis encoder counts + state),
// 2. runs the capture cycle as a move -> settle -> wait -> keep machine: when
// the interval elapses it issues an absolute `MOVE <yaw>,<pitch>` to the next
// target, waits until both axes report standstill at that target, waits a
// further settle_wait_ms so the free-running camera has exposed a frame with
// the gimbal at rest, then keeps the newest frame. If none is ready it
// 2. runs the capture cycle. The interval is the time from one kept frame to
// the next: as soon as a frame is kept it issues an absolute
// `MOVE <yaw>,<pitch>` to the next target, waits until both axes report
// standstill there, waits for the exposure to settle, then waits out what
// is left of the interval and keeps the newest frame. If none is ready it
// retries each tick, and gives up on the waypoint after frame_timeout_ms.
//
// The exposure wait is settle_wait_ms; with the keep rule on (setSettleRule)
// a scan waypoint then also waits until the camera's frames say the image
// has settled (SettleRule), at most frame_timeout_ms more - after which the
// newest frame is kept anyway, with a warning.
//
// When the move and the exposure wait alone take longer than the interval,
// that waypoint warns - except the return from the last waypoint to the
// first, and the first move after a mode starts, which are long by nature.
//
// What it captures is the mode (OperatingMode.h): auto sweeps the ScanGrid front
// to back, repeating; pan walks panHeadings() left to right at the pan's pitch,
// then jumps back to the left edge; manual captures nothing, bar a snapshot or
@ -59,20 +69,38 @@ public:
bool snapshotPending() const { return snapshot_pending_; }
// Continuous capture (`capture on|off`): in manual, keep a frame on the scan's
// own cycle - the interval, then standstill + settle wait - wherever the
// operator has aimed. The gimbal stays free to nudge; a frame is only kept at
// rest. Ignored outside manual, and cleared by any setMode().
// own cycle - one per interval since the last, once standstill + the exposure
// wait are done - wherever the operator has aimed. The gimbal stays free to
// nudge; a frame is only kept at rest. No overrun warnings: the operator may be
// jogging. Ignored outside manual, and cleared by any setMode().
void setContinuous(bool on);
bool continuous() const { return continuous_; }
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().
// for the exposure to settle, 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.
@ -85,20 +113,50 @@ public:
long yawTargetCounts() const { return yaw_target_; }
long pitchTargetCounts() const { return pitch_target_; }
bool moving() const { return moving_; }
// Where the current waypoint is: "moving", "settling" (exposure) or "waiting"
// (for the rest of the interval). For the status line.
const char* phase() const;
// Waypoints that warned for running past the interval since construction.
int overruns() const { return overruns_; }
private:
// One frame's way to the camera, from standstill: when the gimbal came to
// rest, when the exposure wait ended, when the trigger fell due (0 = not yet).
struct Hold {
long long settled_ms = 0;
long long ready_ms = 0;
long long due_ms = 0;
};
enum class Shot { Waiting, Captured, TimedOut };
long long elapsedMs() const;
void resetTimer();
void sendMove(); // emit MOVE to (yaw_target_, pitch_target_)
bool settledAt(const MotorTelemetry& t) const;
bool startNextMove(); // pick the next target and MOVE; false if none
void finishWaypoint();
std::string waypointLabel() const; // "waypoint 3/15" / "pan position 2/6"
void serviceSnapshot(const MotorTelemetry& t);
void serviceContinuous(const MotorTelemetry& t);
// Standstill seen: begin the exposure wait.
void beginHold(Hold& h);
// Whether the exposure wait that began at h.settled_ms is over (sets
// h.ready_ms): settle_wait_ms, then with `keep_rule` the keep rule too.
bool dwellOver(Hold& h, bool keep_rule);
// The keep rule, fed the frames since it last looked: true once it has
// decided - settled, at the exposure limits, timed out (with a warning), or
// no frame data to judge.
bool ruleDecided(long long since_settled);
// The save gate, with one log line when a hold starts and one when it ends.
bool roomToSave();
// Trigger until a frame comes or frame_timeout_ms passes since it fell due.
Shot fire(Hold& h);
// The part a snapshot and a continuous-capture frame share: wait for the
// gimbal to stand still, then the settle wait, then trigger until the frame
// 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);
// gimbal to stand still, then the exposure wait, then (continuous only) the
// rest of the interval, then trigger until the frame budget runs out.
Shot shootAtRest(const MotorTelemetry& t, Hold& h, bool wait_interval);
IMotorController& motor_;
ICameraSource& camera_;
@ -106,8 +164,11 @@ 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;
double interval_ms_; // 1000 / image_rate_; 0 = never
long long timer_start_ = 0; // the last frame kept or given up, or the mode start
Mode mode_ = Mode::Manual;
PanSpec pan_;
@ -115,8 +176,17 @@ private:
std::vector<double> pan_headings_;
size_t pan_index_ = 0;
bool moving_ = false; // a MOVE has been issued, awaiting settle
long long settled_at_ms_ = 0; // when standstill was first seen (0 = not yet)
bool moving_ = false; // a MOVE has been issued, its frame not yet kept
Hold hold_; // the current waypoint's frame
long long move_sent_ms_ = 0;
bool first_move_ = true; // no move yet since setMode()
bool quiet_overrun_ = false; // this waypoint is a long move by nature
int overruns_ = 0;
bool save_held_ = false; // the next move waited for saving to catch up
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
int settle_wait_ms_ = 1000;
int frame_timeout_ms_ = 2000;
bool pitch_present_ = false; // a P: segment was seen in telemetry (2-axis rig)
@ -126,9 +196,9 @@ private:
bool pan_unreachable_warned_ = false;
bool snapshot_pending_ = false; // a manual snapshot is armed
long long snapshot_settled_ms_ = 0; // when standstill was first seen (0 = not yet)
Hold snapshot_hold_;
bool continuous_ = false; // `capture on`
long long continuous_settled_ms_ = 0;
Hold continuous_hold_;
long yaw_target_ = 0; // current target, encoder counts
long pitch_target_ = 0;
};

View File

@ -16,7 +16,7 @@ namespace fgc {
struct GeneralConfig {
std::string tower_name = "Unnamed";
int image_interval = 5; // seconds between captures
double image_interval = 5; // desired seconds from one picture to the next
bool debug = false; // print motor telemetry each loop tick
};
@ -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, section 3.3 "Keep rules"):
//
// "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,19 +157,47 @@ 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;
};
// Read a float/string feature, returning `fallback`/"" if absent (tolerant, for telemetry).
// Read a numeric/string feature, returning `fallback`/"" if absent (tolerant, for
// telemetry). Numeric takes an integer feature too: some are Int on one model and
// Float on another (IntensityControllerTolerance is Int on the Alvium 1800 U-1240c).
double getFloat(const CameraPtr& cam, const char* name, double fallback = 0.0) {
FeaturePtr f;
double v = fallback;
if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess && f->GetValue(v) == VmbErrorSuccess)
return v;
if (SP_ACCESS(cam)->GetFeatureByName(name, f) != VmbErrorSuccess) return fallback;
double v = fallback;
if (f->GetValue(v) == VmbErrorSuccess) return v;
VmbInt64_t i = 0;
if (f->GetValue(i) == VmbErrorSuccess) return static_cast<double>(i);
return fallback;
}
std::string getString(const CameraPtr& cam, const char* name) {
@ -231,6 +302,15 @@ struct VimbaCameraSource::Impl {
for (auto& cam : cameras) e = std::max(e, getFloat(cam, "ExposureTime"));
return e;
}
// Longest frame period any camera runs at (the rate it reports), 0 if unknown.
double slowestFramePeriodMs() {
double p = 0;
for (auto& cam : cameras) {
const double fps = getFloat(cam, "AcquisitionFrameRate");
if (fps > 0) p = std::max(p, 1000.0 / fps);
}
return p;
}
// Reboot one camera, reopen it by id and re-apply its profile. See
// CameraStall.h for why this exists.
@ -448,7 +528,15 @@ bool VimbaCameraSource::trigger() {
}
if (newest <= impl_->last_captured_ts) {
LOG_TRACE_CAT(LogCat::Camera) << "stream stale; restarting acquisition";
// Nothing newer than the last frame kept - but with long exposures the
// next one may simply not be due yet. Restarting now would throw away the
// exposure in progress, so wait while that is still plausible.
const long long since_newest = nowMs() - newest;
if (onStaleFrame(since_newest, impl_->longestExposureUs(),
impl_->slowestFramePeriodMs()) == StallAction::Wait)
return false;
LOG_TRACE_CAT(LogCat::Camera) << "stream stale (" << since_newest
<< " ms since the newest frame); restarting acquisition";
for (auto& s : impl_->stats) s->restarts.fetch_add(1);
try {
impl_->stopAcquisition();
@ -633,6 +721,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 {
@ -1015,7 +1041,7 @@ struct Application::Impl {
a.status = "finding endstops…";
} else if (scheduler && scheduler->captureActive()) {
a.active = true;
const char* phase = scheduler->moving() ? "moving" : "dwell";
const char* phase = scheduler->phase();
if (scheduler->mode() == Mode::Pan) {
a.title = "PAN";
const auto& h = scheduler->panHeadingsNow();
@ -2072,10 +2098,9 @@ struct Application::Impl {
scheduler->setContinuous(true);
const double interval_s =
scheduler->imageRate() > 0.0 ? 1.0 / scheduler->imageRate() : 0.0;
LOG_INFO << "continuous capture on: one image every ~"
<< interval_s + scheduler->settleWaitMs() / 1000.0 << " s ("
<< interval_s << " s interval + " << scheduler->settleWaitMs()
<< " ms settle), kept only while the gimbal stands still";
LOG_INFO << "continuous capture on: one image every " << interval_s
<< " s, each once the gimbal has stood still for "
<< scheduler->settleWaitMs() << " ms";
}
// End continuous capture because something else is taking the gimbal or the
@ -3014,7 +3039,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 +3097,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

@ -14,6 +14,17 @@ StallAction onNoFirstFrame(long long waited_ms, double exposure_us, int failed_r
return failed_restarts >= kRestartsBeforeReset ? StallAction::Reset : StallAction::Restart;
}
long long staleFrameLimitMs(double exposure_us, double frame_period_ms) {
const double exposure_ms = exposure_us > 0 ? exposure_us / 1000 : 0;
const double slowest = std::max(exposure_ms, frame_period_ms > 0 ? frame_period_ms : 0);
return std::max<long long>(2000, static_cast<long long>(2 * slowest) + 1000);
}
StallAction onStaleFrame(long long since_newest_ms, double exposure_us, double frame_period_ms) {
return since_newest_ms < staleFrameLimitMs(exposure_us, frame_period_ms) ? StallAction::Wait
: StallAction::Restart;
}
StallAction onRestartFailed(int failed_restarts) {
return failed_restarts >= kRestartsBeforeReset ? StallAction::Reset : StallAction::Restart;
}

View File

@ -4,8 +4,11 @@
#include "fgc/Logger.h"
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <iomanip>
#include <optional>
#include <sstream>
namespace fgc {
@ -21,6 +24,13 @@ long long steadyNowMs() {
// deadband (~200 counts).
constexpr long kSettleTolCounts = 600;
// Seconds with one decimal, for the overrun warning.
std::string seconds(double ms) {
std::ostringstream o;
o << std::fixed << std::setprecision(1) << ms / 1000.0 << " s";
return o.str();
}
} // namespace
CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camera,
@ -31,7 +41,8 @@ CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camer
geometry_(geometry),
grid_(grid),
now_ms_(now_ms ? std::move(now_ms) : steadyNowMs),
image_rate_(image_rate) {
image_rate_(image_rate),
interval_ms_(image_rate > 0.0 ? 1000.0 / image_rate : 0.0) {
timer_start_ = now_ms_();
}
@ -42,26 +53,37 @@ void CaptureScheduler::setMode(Mode mode, const PanSpec& pan) {
pan_index_ = 0;
pan_unreachable_warned_ = false;
moving_ = false; // restart the move/settle cycle cleanly
settled_at_ms_ = 0;
hold_ = {};
// The first move goes from wherever the gimbal is, so it may be long: no
// overrun warning for it.
first_move_ = true;
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.
snapshot_pending_ = false;
// Continuous capture is a manual-mode activity: any mode change ends it, so
// a later return to manual never resumes shooting nobody asked for.
continuous_ = false;
continuous_settled_ms_ = 0;
continuous_ = false;
continuous_hold_ = {};
// The first frame comes no sooner than one interval after the mode starts.
resetTimer();
}
void CaptureScheduler::setContinuous(bool on) {
continuous_ = on;
continuous_settled_ms_ = 0;
continuous_ = on;
continuous_hold_ = {};
// The first frame comes one interval after switching on, as the first scan
// waypoint does after `mode auto`.
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,38 +107,117 @@ bool CaptureScheduler::settledAt(const MotorTelemetry& t) const {
std::labs(t.pitch.xenc - pitch_target_) <= kSettleTolCounts;
}
void CaptureScheduler::requestSnapshot() {
snapshot_pending_ = true;
snapshot_settled_ms_ = 0;
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;
}
// Wait for rest, settle, then shoot - the tail of a scan waypoint without the
// move. A snapshot and a continuous-capture frame both come through here, so
// either is exactly what a scan waypoint produces: same file, same CamEvent.
//
// Standstill only, no target check: the operator aims, so there is no target.
// The frame budget is measured from settling, not from the request - a request
// made mid-slew must not spend the camera's budget on the slew.
CaptureScheduler::Shot CaptureScheduler::shootAtRest(const MotorTelemetry& t,
long long& settled_ms) {
const bool standstill = t.yaw.standstill && (!pitch_present_ || t.pitch.standstill);
if (!standstill) {
settled_ms = 0; // still slewing (e.g. a jog is in flight)
return Shot::Waiting;
const char* CaptureScheduler::phase() const {
if (!moving_ || hold_.settled_ms == 0) return "moving";
return hold_.ready_ms == 0 ? "settling" : "waiting";
}
std::string CaptureScheduler::waypointLabel() const {
if (mode_ == Mode::Pan)
return "pan position " + std::to_string(pan_index_ + 1) + "/" +
std::to_string(pan_headings_.size());
return "waypoint " + std::to_string(grid_.index() + 1) + "/" + std::to_string(grid_.size());
}
void CaptureScheduler::requestSnapshot() {
snapshot_pending_ = true;
snapshot_hold_ = {};
}
void CaptureScheduler::beginHold(Hold& h) {
h = {};
h.settled_ms = now_ms_();
}
// From here on, watch the frames the camera delivers until brightness says the
// image has settled, or the frame budget runs out.
bool CaptureScheduler::ruleDecided(long long since_settled) {
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 (settled_ms == 0) {
settled_ms = now_ms_();
return Shot::Waiting;
if (rule_seq_ == UINT64_MAX) return true;
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;
}
const long long since_settled = now_ms_() - settled_ms;
if (since_settled < settle_wait_ms_) return Shot::Waiting;
if (v == SettleRule::Verdict::Waiting) {
if (since_settled < settle_wait_ms_ + frame_timeout_ms_) return false; // keep watching
LOG_WARN << waypointLabel() << ": 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
return true;
}
bool CaptureScheduler::dwellOver(Hold& h, bool keep_rule) {
if (h.ready_ms != 0) return true;
const long long since = now_ms_() - h.settled_ms;
if (since < settle_wait_ms_) return false;
if (keep_rule && use_rule_ && !ruleDecided(since)) return false;
h.ready_ms = now_ms_();
return true;
}
CaptureScheduler::Shot CaptureScheduler::fire(Hold& h) {
if (h.due_ms == 0) h.due_ms = now_ms_();
if (camera_.trigger()) return Shot::Captured;
if (since_settled > settle_wait_ms_ + frame_timeout_ms_) return Shot::TimedOut;
if (now_ms_() - h.due_ms >= frame_timeout_ms_) return Shot::TimedOut;
return Shot::Waiting; // no fresh frame yet; retry next tick
}
// Wait for rest and the exposure, then shoot - the tail of a scan waypoint
// without the move. A snapshot and a continuous-capture frame both come through
// here, so either is exactly what a scan waypoint produces: same file, same
// CamEvent.
//
// Standstill only, no target check: the operator aims, so there is no target.
// Movement restarts the wait. The frame budget is measured from when the trigger
// fell due, not from the request - a request made mid-slew must not spend the
// camera's budget on the slew.
CaptureScheduler::Shot CaptureScheduler::shootAtRest(const MotorTelemetry& t, Hold& h,
bool wait_interval) {
const bool standstill = t.yaw.standstill && (!pitch_present_ || t.pitch.standstill);
if (!standstill) {
h = {}; // still slewing (e.g. a jog is in flight)
return Shot::Waiting;
}
if (h.settled_ms == 0) {
beginHold(h);
return Shot::Waiting;
}
if (!dwellOver(h, false)) return Shot::Waiting;
if (wait_interval) {
if (interval_ms_ <= 0.0 || elapsedMs() < interval_ms_) return Shot::Waiting;
// Continuous capture holds, as the scan does, while saving is behind.
if (h.due_ms == 0 && !roomToSave()) return Shot::Waiting;
}
return fire(h);
}
void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) {
switch (shootAtRest(t, snapshot_settled_ms_)) {
switch (shootAtRest(t, snapshot_hold_, false)) {
case Shot::Waiting:
return;
case Shot::Captured:
@ -124,20 +225,17 @@ void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) {
break;
case Shot::TimedOut:
LOG_WARN << "snapshot: no frame within " << frame_timeout_ms_
<< " ms of settling; send 'snapshot' (Space) again";
<< " ms; send 'snapshot' (Space) again";
break;
}
snapshot_pending_ = false;
snapshot_hold_ = {};
}
// The scan's cycle without the move: wait the interval, then shoot at rest.
// The timer restarts after each frame kept or given up, exactly as a scan's
// does, so the cadence matches what a scan achieves rather than the bare
// interval.
// The scan's cycle without the move: one frame per interval since the last,
// once at rest and the exposure has settled.
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;
switch (shootAtRest(t, continuous_settled_ms_)) {
switch (shootAtRest(t, continuous_hold_, true)) {
case Shot::Waiting:
return;
case Shot::Captured:
@ -145,10 +243,92 @@ void CaptureScheduler::serviceContinuous(const MotorTelemetry& t) {
break;
case Shot::TimedOut:
LOG_WARN << "continuous capture: no frame within " << frame_timeout_ms_
<< " ms of settling; trying again next interval";
<< " ms; trying again next interval";
break;
}
continuous_settled_ms_ = 0;
continuous_hold_ = {};
resetTimer();
}
// Pick the next target, skipping unreachable ones in this same tick (a skip
// costs no time and does not restart the interval), and MOVE there.
bool CaptureScheduler::startNextMove() {
// A move that starts a pass again - back from the last waypoint to the first -
// is long by nature and the interval is not expected to hold for it; nor for
// the first move after a mode starts, from wherever the gimbal was.
bool pass_start = false;
std::optional<long> yc;
if (mode_ == Mode::Auto) {
if (grid_.empty()) return false;
pass_start = grid_.index() == 0 && grid_.size() > 1;
// Yaw travel is under a full turn, so some bearings are unreachable.
// Skip such a waypoint rather than clamping to the nearest limit, which
// would image a direction nobody asked for and label it with the one
// they did. Try each at most once per tick, so a grid with none reachable
// just idles.
for (size_t tries = 0; tries < grid_.size(); ++tries) {
yc = geometry_.yaw.headingToCounts(grid_.current().yaw_deg);
if (yc) break;
if (!unreachable_warned_) {
unreachable_warned_ = true;
LOG_WARN << "scan waypoint " << (grid_.index() + 1) << "/" << grid_.size()
<< " heading " << grid_.current().yaw_deg
<< " deg is outside the reachable yaw arc; skipping it (further "
"unreachable waypoints are skipped silently)";
}
grid_.advance();
}
if (!yc) return false;
const ScanPoint& p = grid_.current();
yaw_target_ = *yc;
pitch_target_ = geometry_.pitch.toCounts(p.pitch_deg);
// One INFO line per move so the operator can follow the scan in the
// log without enabling the control wire-trace.
LOG_INFO << "scan move " << (grid_.index() + 1) << "/" << grid_.size()
<< ": yaw " << p.yaw_deg << " pitch " << p.pitch_deg << " (deg)";
} else { // pan: the next position, left to right, at the pan's pitch
if (pan_headings_.empty()) return false;
pass_start = pan_index_ == 0 && pan_headings_.size() > 1;
// Skip positions outside the reachable arc, as the sweep does.
for (size_t tries = 0; tries < pan_headings_.size(); ++tries) {
yc = geometry_.yaw.headingToCounts(pan_headings_[pan_index_]);
if (yc) break;
if (!pan_unreachable_warned_) {
pan_unreachable_warned_ = true;
LOG_WARN << "pan position " << (pan_index_ + 1) << "/" << pan_headings_.size()
<< " heading " << pan_headings_[pan_index_]
<< " deg is outside the reachable yaw arc; skipping it (further "
"unreachable positions are skipped silently)";
}
pan_index_ = (pan_index_ + 1) % pan_headings_.size();
}
if (!yc) return false;
yaw_target_ = *yc;
pitch_target_ = geometry_.pitch.toCounts(pan_.pitch);
LOG_INFO << "pan move " << (pan_index_ + 1) << "/" << pan_headings_.size()
<< ": yaw " << pan_headings_[pan_index_] << " pitch " << pan_.pitch
<< " (deg)";
}
// Time spent waiting for saving is already announced, so not an overrun.
quiet_overrun_ = first_move_ || pass_start || save_held_;
first_move_ = false;
save_held_ = false;
sendMove();
moving_ = true;
hold_ = {};
rule_started_ = false;
move_sent_ms_ = now_ms_();
return true;
}
// The waypoint's frame is kept or given up: on to the next, and the interval
// counts from now.
void CaptureScheduler::finishWaypoint() {
moving_ = false;
hold_ = {};
rule_started_ = false;
if (mode_ == Mode::Auto) grid_.advance();
else if (!pan_headings_.empty()) pan_index_ = (pan_index_ + 1) % pan_headings_.size();
resetTimer();
}
@ -168,108 +348,63 @@ void CaptureScheduler::tick() {
}
// 3. Capture cycle: the next scan waypoint (auto) or pan position (pan).
const double interval_ms = image_rate_ > 0.0 ? 1000.0 / image_rate_ : 0.0;
// Move straight away - while there is room to save what it will capture -
// and wait out what is left of the interval at the end.
if (interval_ms_ <= 0.0) return;
if (!moving_) {
// Issue the next move once the interval has elapsed.
if (interval_ms <= 0.0 || elapsedMs() <= interval_ms) return;
if (mode_ == Mode::Auto) {
if (grid_.empty()) return;
const ScanPoint& p = grid_.current();
// Yaw travel is under a full turn, so some bearings are unreachable.
// Skip such a waypoint rather than clamping to the nearest limit,
// which would image a direction nobody asked for and label it with
// the one they did.
auto yc = geometry_.yaw.headingToCounts(p.yaw_deg);
if (!yc) {
if (!unreachable_warned_) {
unreachable_warned_ = true;
LOG_WARN << "scan waypoint " << (grid_.index() + 1) << "/" << grid_.size()
<< " heading " << p.yaw_deg
<< " deg is outside the reachable yaw arc; skipping it (further "
"unreachable waypoints are skipped silently)";
}
grid_.advance();
resetTimer();
return;
}
yaw_target_ = *yc;
pitch_target_ = geometry_.pitch.toCounts(p.pitch_deg);
// One INFO line per move so the operator can follow the scan in the
// log without enabling the control wire-trace.
LOG_INFO << "scan move " << (grid_.index() + 1) << "/" << grid_.size()
<< ": yaw " << p.yaw_deg << " pitch " << p.pitch_deg << " (deg)";
} else { // pan: the next position, left to right, at the pan's pitch
if (pan_headings_.empty()) return;
// Skip positions outside the reachable arc, as the sweep does; try
// each at most once per tick so a pan with none reachable just idles.
std::optional<long> yc;
for (size_t tries = 0; tries < pan_headings_.size() && !yc; ++tries) {
yc = geometry_.yaw.headingToCounts(pan_headings_[pan_index_]);
if (yc) break;
if (!pan_unreachable_warned_) {
pan_unreachable_warned_ = true;
LOG_WARN << "pan position " << (pan_index_ + 1) << "/" << pan_headings_.size()
<< " heading " << pan_headings_[pan_index_]
<< " deg is outside the reachable yaw arc; skipping it (further "
"unreachable positions are skipped silently)";
}
pan_index_ = (pan_index_ + 1) % pan_headings_.size();
}
if (!yc) return;
yaw_target_ = *yc;
pitch_target_ = geometry_.pitch.toCounts(pan_.pitch);
LOG_INFO << "pan move " << (pan_index_ + 1) << "/" << pan_headings_.size()
<< ": yaw " << pan_headings_[pan_index_] << " pitch " << pan_.pitch
<< " (deg)";
if (!roomToSave()) {
save_held_ = true;
return;
}
sendMove();
moving_ = true;
settled_at_ms_ = 0;
resetTimer();
startNextMove();
return;
}
// Moving: wait for standstill at the target, then hold still a moment before
// keeping a frame.
if (settled_at_ms_ == 0) {
// Moving: wait for standstill at the target.
if (hold_.settled_ms == 0) {
if (!settledAt(t)) return;
settled_at_ms_ = now_ms_();
beginHold(hold_);
LOG_TRACE_CAT(LogCat::Control)
<< "settled at yaw=" << t.yaw.xenc << " pitch=" << t.pitch.xenc << "; waiting "
<< settle_wait_ms_ << " ms before capture";
<< "settled at yaw=" << t.yaw.xenc << " pitch=" << t.pitch.xenc
<< "; waiting " << settle_wait_ms_ << " ms"
<< (use_rule_ ? " and for the image to settle" : "") << " before capture";
return;
}
// The camera free-runs, so this pause is what makes the kept frame one that was
// exposed with the gimbal at rest (no slew blur) and with the on-camera auto
// exposure settled on this waypoint's light.
const long long since_settled = now_ms_() - settled_at_ms_;
if (since_settled < settle_wait_ms_) return;
if (camera_.trigger()) {
if (mode_ == Mode::Auto)
LOG_INFO << "captured waypoint " << (grid_.index() + 1) << "/" << grid_.size();
else
LOG_INFO << "captured pan position " << (pan_index_ + 1) << "/"
<< pan_headings_.size();
} else if (since_settled < settle_wait_ms_ + frame_timeout_ms_) {
return; // no fresh frame yet; retry next tick
} else {
// Never park on one waypoint: a stalled camera would otherwise stop the
// whole sweep rather than costing it a single image.
LOG_WARN << "no frame within " << frame_timeout_ms_ << " ms of settling; skipping "
<< (mode_ == Mode::Auto ? "waypoint " : "pan position ")
<< (mode_ == Mode::Auto ? grid_.index() + 1 : pan_index_ + 1) << "/"
<< (mode_ == Mode::Auto ? grid_.size() : pan_headings_.size());
// The camera free-runs, so this wait is what makes the kept frame one that
// was exposed with the gimbal at rest (no slew blur) and with the on-camera
// auto exposure settled on this waypoint's light.
if (hold_.ready_ms == 0) {
if (!dwellOver(hold_, true)) return;
const long long used = hold_.ready_ms - timer_start_;
if (static_cast<double>(used) > interval_ms_ && !quiet_overrun_) {
++overruns_;
LOG_WARN << waypointLabel() << ": " << seconds(static_cast<double>(used))
<< " since the last picture, over the " << seconds(interval_ms_)
<< " interval (move "
<< seconds(static_cast<double>(hold_.settled_ms - move_sent_ms_))
<< ", exposure "
<< seconds(static_cast<double>(hold_.ready_ms - hold_.settled_ms)) << ")";
}
}
moving_ = false;
settled_at_ms_ = 0;
if (mode_ == Mode::Auto) grid_.advance();
else if (!pan_headings_.empty()) pan_index_ = (pan_index_ + 1) % pan_headings_.size();
resetTimer();
// Then whatever is left of the interval.
if (static_cast<double>(elapsedMs()) < interval_ms_) return;
switch (fire(hold_)) {
case Shot::Waiting:
return;
case Shot::Captured:
LOG_INFO << "captured " << waypointLabel();
break;
case Shot::TimedOut:
// Never park on one waypoint: a stalled camera would otherwise stop
// the whole sweep rather than costing it a single image.
LOG_WARN << "no frame within " << frame_timeout_ms_ << " ms; skipping "
<< waypointLabel();
break;
}
finishWaypoint();
}
} // namespace fgc

View File

@ -235,7 +235,7 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
AppConfig cfg;
cfg.general.tower_name = get(kv, "General.tower_name", cfg.general.tower_name);
cfg.general.image_interval = getInt(kv, "General.image_interval", cfg.general.image_interval);
cfg.general.image_interval = getDouble(kv, "General.image_interval", cfg.general.image_interval);
cfg.general.debug = getBool(kv, "General.debug", cfg.general.debug);
// [Board] / [Hardware]: normally from hardware.ini, but read from the same
@ -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");
@ -537,9 +558,9 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
cfg.scan.grid_file = get(kv, "Scan.grid_file", cfg.scan.grid_file);
// ---- validation ----
if (cfg.general.image_interval <= 0)
if (!(cfg.general.image_interval > 0) || !std::isfinite(cfg.general.image_interval))
throw std::runtime_error("General.image_interval must be > 0 (got " +
std::to_string(cfg.general.image_interval) + ")");
get(kv, "General.image_interval") + ")");
if (cfg.serial.baud == 0)
throw std::runtime_error("Serial.baud must be > 0");

View File

@ -42,10 +42,11 @@ const std::vector<HelpSection>& helpCatalog() {
"Waits for standstill and the profile's settle time, as a scan waypoint",
"does, so the file and CamEvent are the same as a scan frame's."}},
{"capture [on|off]", "Continuous capture where the gimbal is aimed (key r).", {
"One image per scan cycle: [General] image_interval, then standstill and",
"the profile's settle time. The gimbal can still be nudged; images are",
"kept only while it stands still. Bare 'capture' reports on/off. Ends on",
"any mode change, homing, heading setup or test."}},
"One image per [General] image_interval since the last, each once the",
"gimbal has stood still for the profile's settle time. The gimbal can",
"still be nudged; images are kept only while it stands still. Bare",
"'capture' reports on/off. Ends on any mode change, homing, heading",
"setup or test."}},
{"set camera profile [<camera>] <name>", "Switch a camera's VimbaX settings profile.", {
"<name> is an XML stem in that camera's profile_dir. The camera may be",
"omitted when only one is configured.",

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

@ -25,3 +25,19 @@ TEST_CASE("a stale stream escalates to a reset once restarts stop working") {
CHECK(onRestartFailed(1) == StallAction::Restart);
CHECK(onRestartFailed(kRestartsBeforeReset) == StallAction::Reset);
}
TEST_CASE("a slow next frame is not a stale stream") {
// Fast stream (5 ms exposure, 5 fps): the 2 s floor.
CHECK(staleFrameLimitMs(5000, 200) == 2000);
// Tower 111 at dusk: 9 s exposures, a frame every ~9 s.
CHECK(staleFrameLimitMs(9e6, 9000) == 2 * 9000 + 1000);
// A frame-rate cap slower than the exposure governs (night: 0.5 s at 0.215 fps).
CHECK(staleFrameLimitMs(5e5, 1000.0 / 0.215) == 2 * 4651 + 1000);
// Asked 8 s after the last frame of a 9 s exposure: wait for it, do not
// restart the stream and throw that exposure away (tower 111, 2026-10-04).
CHECK(onStaleFrame(8000, 9e6, 9000) == StallAction::Wait);
CHECK(onStaleFrame(20000, 9e6, 9000) == StallAction::Restart);
// A fast stream that has delivered nothing new for seconds is stale.
CHECK(onStaleFrame(2500, 5000, 200) == StallAction::Restart);
}

View File

@ -95,9 +95,17 @@ TEST_CASE("environment overrides file credentials") {
TEST_CASE("ConfigLoader validates input") {
CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "0"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "abc"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "-1"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "nan"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"General.debug", "maybe"}}));
}
TEST_CASE("image_interval takes fractions of a second") {
AppConfig c = ConfigLoader::fromMap({{"General.image_interval", "2.5"}});
CHECK(c.general.image_interval == doctest::Approx(2.5));
CHECK(c.image_rate() == doctest::Approx(0.4));
}
TEST_CASE("brokerUri builds a Paho serverURI from host + port") {
// The port used to be whatever Paho defaulted to when handed a bare address.
// It is now explicit, so these cases pin the whole resolution table.
@ -807,3 +815,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 {}
@ -63,36 +75,35 @@ TEST_CASE("CaptureScheduler sweeps the scan grid with MOVE + settle + trigger")
CaptureScheduler sch(motor, cam, 1.0 /*img/s -> 1000ms*/, testGeometry(), grid,
[&] { return clock; });
sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test
sch.setSettleWait(0, 0); // no exposure wait
sch.setMode(Mode::Auto);
// Before the interval elapses: nothing moves.
clock = 500;
sch.tick();
CHECK(motor.cmds.empty());
// After the interval: MOVE to the first grid point (0,0) is issued.
clock = 1600;
// The move goes out straight away: MOVE to the first grid point (0,0).
clock = 10;
sch.tick();
REQUIRE(motor.cmds.size() == 1);
CHECK(motor.cmds[0] == "MOVE 0,0");
CHECK(sch.moving());
CHECK(cam.triggers == 0); // not yet settled
// Report standstill at the target -> camera fires, cursor advances.
// At rest early: the frame waits for the interval, counted from the mode start.
settleAt(motor, 0, 0);
clock = 1700;
sch.tick(); // first tick records standstill
clock = 1710;
sch.tick(); // next one keeps the frame
clock = 100;
sch.tick(); // records standstill
clock = 200;
sch.tick(); // exposure settled, interval not over
CHECK(cam.triggers == 0);
CHECK(std::string(sch.phase()) == "waiting");
clock = 1000;
sch.tick(); // interval over: the frame is kept, the cursor advances
CHECK(cam.triggers == 1);
CHECK_FALSE(sch.moving());
// Next interval: MOVE to the second grid point (90 deg -> 900 counts).
clock = 2800;
// And the next move goes out on the next tick: (90 deg -> 900 counts).
clock = 1010;
sch.tick();
CHECK(motor.cmds.back() == "MOVE 900,0");
CHECK(sch.overruns() == 0);
}
TEST_CASE("CaptureScheduler scans a 1-axis (yaw-only) rig with yaw-only MOVE/settle") {
@ -251,11 +262,12 @@ TEST_CASE("CaptureScheduler holds still for settle_wait_ms before keeping a fram
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
CaptureScheduler sch(motor, cam, 0.2 /*5000 ms*/, testGeometry(), grid,
[&] { return clock; });
sch.setSettleWait(1000, 2000);
sch.setMode(Mode::Auto);
clock = 1600;
clock = 10;
sch.tick();
REQUIRE(motor.cmds.size() == 1);
@ -264,17 +276,153 @@ TEST_CASE("CaptureScheduler holds still for settle_wait_ms before keeping a fram
clock = 2000;
sch.tick();
CHECK(cam.triggers == 0);
CHECK(std::string(sch.phase()) == "settling");
clock = 2900; // 900 ms after settling: still inside the wait
sch.tick();
CHECK(cam.triggers == 0);
// Past the wait, the newest frame is kept and the sweep advances.
// Past the wait, but the interval still has time left: wait it out.
clock = 3100;
sch.tick();
CHECK(cam.triggers == 0);
CHECK(std::string(sch.phase()) == "waiting");
clock = 5000;
sch.tick();
CHECK(cam.triggers == 1);
CHECK_FALSE(sch.moving());
}
TEST_CASE("the interval is counted from the last frame, so pictures come one interval apart") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}, {30.0, 0.0}, {60.0, 0.0}});
CaptureScheduler sch(motor, cam, 0.5 /*2000 ms*/, testGeometry(), grid,
[&] { return clock; });
sch.setSettleWait(500, 2000);
sch.setMode(Mode::Auto);
std::vector<long long> shots;
// 10 ms ticks; the gimbal takes 700 ms to reach each target.
long long arrive = -1;
size_t moves = 0;
for (clock = 0; clock <= 7000; clock += 10) {
if (motor.cmds.size() != moves) {
moves = motor.cmds.size();
arrive = clock + 700;
motor.tel.yaw.standstill = false;
}
if (arrive >= 0 && clock >= arrive)
settleAt(motor, sch.yawTargetCounts(), sch.pitchTargetCounts());
const int before = cam.triggers;
sch.tick();
if (cam.triggers != before) shots.push_back(clock);
}
// Move 700 + settle 500 fit inside 2000, so frames land 2000 ms apart.
REQUIRE(shots.size() >= 3);
CHECK(shots[0] == 2000);
CHECK(shots[1] == 4000);
CHECK(shots[2] == 6000);
CHECK(sch.overruns() == 0);
}
TEST_CASE("a waypoint whose move and settle outlast the interval warns, and shoots when ready") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}, {30.0, 0.0}, {60.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0 /*1000 ms*/, testGeometry(), grid,
[&] { return clock; });
sch.setSettleWait(500, 2000);
sch.setMode(Mode::Auto);
// First waypoint: the move from wherever the gimbal was is quiet even when long.
clock = 0; sch.tick(); // MOVE 0,0
clock = 3000; settleAt(motor, 0, 0); sch.tick();
clock = 3500; sch.tick(); // settled + 500: kept at once
CHECK(cam.triggers == 1);
CHECK(sch.overruns() == 0);
// Second: 900 ms of travel + 500 settle > 1000 ms interval.
clock = 3510; sch.tick(); // MOVE 300,0
REQUIRE(motor.cmds.back() == "MOVE 300,0");
motor.tel.yaw.standstill = false;
clock = 4410; settleAt(motor, 300, 0); sch.tick();
clock = 4910; sch.tick(); // 1410 ms since the last frame
CHECK(cam.triggers == 2); // no extra wait: shot as soon as ready
CHECK(sch.overruns() == 1);
}
TEST_CASE("no overrun warning on the return from the last waypoint to the first") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}, {30.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 2000);
sch.setMode(Mode::Auto);
// Waypoint 1 (first move: quiet), waypoint 2 within the interval.
clock = 0; sch.tick();
settleAt(motor, 0, 0);
clock = 100; sch.tick();
clock = 1000; sch.tick();
CHECK(cam.triggers == 1);
clock = 1010; sch.tick(); // MOVE 300,0
settleAt(motor, 300, 0);
clock = 1100; sch.tick();
clock = 1200; sch.tick(); // ready well inside the interval
clock = 2010; sch.tick();
CHECK(cam.triggers == 2);
// The long way back to waypoint 1: well over the interval, but quiet.
clock = 2020; sch.tick();
REQUIRE(motor.cmds.back() == "MOVE 0,0");
motor.tel.yaw.standstill = false;
clock = 6000; settleAt(motor, 0, 0); sch.tick();
clock = 6010; sch.tick();
CHECK(cam.triggers == 3);
CHECK(sch.overruns() == 0);
// Waypoint 2 again, also too slow: this one warns.
clock = 6020; sch.tick();
motor.tel.yaw.standstill = false;
clock = 8000; settleAt(motor, 300, 0); sch.tick();
clock = 8010; sch.tick();
CHECK(cam.triggers == 4);
CHECK(sch.overruns() == 1);
}
TEST_CASE("an unreachable waypoint is skipped without restarting the interval") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
// 150 deg is past the 90 deg limit.
ScanGrid grid({{0.0, 0.0}, {150.0, 0.0}, {30.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 2000);
sch.setMode(Mode::Auto);
clock = 0; sch.tick();
settleAt(motor, 0, 0);
clock = 100; sch.tick();
clock = 1000; sch.tick();
REQUIRE(cam.triggers == 1);
// Straight past waypoint 2 to waypoint 3 in one tick.
clock = 1010; sch.tick();
CHECK(motor.cmds.back() == "MOVE 300,0");
CHECK(grid.index() == 2);
settleAt(motor, 300, 0);
clock = 1100; sch.tick();
clock = 2000; sch.tick(); // one interval after the last frame
CHECK(cam.triggers == 2);
}
TEST_CASE("CaptureScheduler abandons a waypoint after frame_timeout_ms") {
long long clock = 0;
FakeMotor motor;
@ -286,20 +434,22 @@ TEST_CASE("CaptureScheduler abandons a waypoint after frame_timeout_ms") {
sch.setSettleWait(1000, 2000);
sch.setMode(Mode::Auto);
clock = 1600;
clock = 10;
sch.tick();
settleAt(motor, 0, 0);
clock = 1700;
clock = 100;
sch.tick();
// Retries through the timeout window without giving up.
clock = 3500; // 1800 ms after settling: past the wait, inside the timeout
// Due at 1100 (settled + 1000 > interval); retries through the timeout window.
clock = 1100;
sch.tick();
clock = 2900; // 1800 ms after falling due: inside the timeout
sch.tick();
CHECK(sch.moving());
CHECK(grid.index() == 0);
// Past wait + timeout, the waypoint is skipped rather than blocking the sweep.
clock = 4800;
// Past the timeout, the waypoint is skipped rather than blocking the sweep.
clock = 3200;
sch.tick();
CHECK_FALSE(sch.moving());
CHECK(grid.index() == 1);
@ -453,11 +603,11 @@ TEST_CASE("a snapshot that times out can simply be asked for again") {
// --- continuous capture (`capture on`) -------------------------------------
//
// The scan's cycle without the move: interval, then standstill + settle, then
// one frame, and the timer restarts from there - so the cadence is what a scan
// The scan's cycle without the move: standstill + settle, then the rest of the
// interval since the last frame, then one frame - so the cadence is what a scan
// achieves, and the gimbal stays free to be aimed in between.
TEST_CASE("continuous capture keeps one frame per interval + settle, in manual") {
TEST_CASE("continuous capture keeps one frame per interval, in manual") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
@ -470,22 +620,21 @@ TEST_CASE("continuous capture keeps one frame per interval + settle, in manual")
sch.setContinuous(true);
CHECK(sch.continuous());
clock = 900; sch.tick(); // inside the interval
clock = 100; sch.tick(); // standstill seen, settle starts
clock = 700; sch.tick(); // settled, interval not over
CHECK(cam.triggers == 0);
clock = 1100; sch.tick(); // interval over: standstill seen, settle starts
CHECK(cam.triggers == 0);
clock = 1500; sch.tick(); // still settling
CHECK(cam.triggers == 0);
clock = 1700; sch.tick(); // settled: shoot
clock = 1000; sch.tick(); // interval over: shoot
CHECK(cam.triggers == 1);
CHECK(motor.cmds.empty()); // never moves the gimbal
// The timer restarted at the capture, so the next frame is a whole cycle on.
clock = 2600; sch.tick();
// The timer restarted at the capture, so the next frame is one interval on.
clock = 1100; sch.tick(); // standstill seen
clock = 1700; sch.tick(); // settled
clock = 1900; sch.tick();
CHECK(cam.triggers == 1);
clock = 2800; sch.tick(); // interval over, standstill seen
clock = 3400; sch.tick(); // + settle
clock = 2000; sch.tick();
CHECK(cam.triggers == 2);
CHECK(sch.overruns() == 0);
}
TEST_CASE("continuous capture waits for the gimbal to stop before keeping a frame") {
@ -507,6 +656,7 @@ TEST_CASE("continuous capture waits for the gimbal to stop before keeping a fram
CHECK(cam.triggers == 0);
clock = 5700; sch.tick();
CHECK(cam.triggers == 1);
CHECK(sch.overruns() == 0); // no waypoints, no overrun warnings
}
TEST_CASE("continuous capture gives up on a cycle without a frame and carries on") {
@ -520,18 +670,18 @@ TEST_CASE("continuous capture gives up on a cycle without a frame and carries on
settleAt(motor, 0, 0);
sch.setContinuous(true);
clock = 1100; sch.tick(); // standstill seen
clock = 1200; sch.tick(); // tries, no frame
clock = 100; sch.tick(); // standstill seen
clock = 1000; sch.tick(); // due: tries, no frame
CHECK(cam.triggers == 1);
clock = 2200; sch.tick(); // past the budget: give up, timer restarts
clock = 2000; sch.tick(); // past the budget: give up, timer restarts
const int after_giving_up = cam.triggers;
clock = 2500; sch.tick(); // new cycle not due yet
clock = 2500; sch.tick(); // standstill seen; next frame not due yet
clock = 2600; sch.tick();
CHECK(cam.triggers == after_giving_up);
CHECK(sch.continuous()); // still on
cam.ok = true;
clock = 3300; sch.tick(); // next cycle: standstill seen
clock = 3400; sch.tick();
clock = 3000; sch.tick(); // one interval after giving up
CHECK(cam.triggers == after_giving_up + 1);
}
@ -575,3 +725,192 @@ 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 = 1400; sch.tick(); // the next move is due, 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());
// The hold is long past the interval, but it was announced as such: that
// waypoint is not also an overrun.
motor.tel.yaw.standstill = false;
clock = 9200; settleAt(motor, 900, 0); sch.tick();
clock = 9300; sch.tick();
CHECK(cam.triggers == 2);
CHECK(sch.overruns() == 0);
}
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); // the interval counts from here
clock = 1500; sch.tick(); // standstill seen
clock = 3000; sch.tick(); // settled and due, but no room
CHECK(cam.triggers == 1); // held
CHECK(sch.holdingForSave());
room = true;
clock = 3100; sch.tick(); // kept at once: the gimbal never moved
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);
}
TEST_CASE("with the keep rule, a settled image still waits out the rest of the interval") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
CaptureScheduler sch(motor, cam, 0.2 /*5000 ms*/, testGeometry(), grid,
[&] { return clock; });
sch.setSettleWait(200, 3000);
sch.setSettleRule(true);
cam.deliver(255);
sch.setMode(Mode::Auto);
clock = 0; sch.tick(); // MOVE
settleAt(motor, 0, 0);
clock = 100; sch.tick(); // standstill seen
clock = 300; sch.tick(); // mechanical settle over: rule starts
cam.deliver(126);
clock = 400; sch.tick();
CHECK(std::string(sch.phase()) == "settling");
for (double b : {125.0, 125.5}) {
cam.deliver(b);
clock += 100; sch.tick();
}
CHECK(std::string(sch.phase()) == "waiting"); // settled at 600 ms
CHECK(cam.triggers == 0);
clock = 4990; sch.tick();
CHECK(cam.triggers == 0);
clock = 5000; sch.tick(); // one interval after the mode started
CHECK(cam.triggers == 1);
CHECK(sch.overruns() == 0);
}
TEST_CASE("the keep rule starts afresh at each waypoint") {
RuleRig r;
for (double b : {126.0, 125.0, 125.5}) { // the first waypoint settles
r.cam.deliver(b);
r.clock += 100; r.sch.tick();
}
REQUIRE(r.cam.triggers == 1);
r.clock = 1800; r.sch.tick(); // MOVE to the second
REQUIRE(r.motor.cmds.back() == "MOVE 900,0");
r.motor.tel.yaw.standstill = false;
r.clock = 1900; settleAt(r.motor, 900, 0); r.sch.tick();
r.clock = 2100; r.sch.tick(); // settle over: the rule starts again
r.cam.deliver(230); // new view, far too bright
r.clock = 2800; r.sch.tick(); // interval over, but not settled
CHECK(r.cam.triggers == 1);
for (double b : {126.0, 125.0, 125.5}) {
r.cam.deliver(b);
r.clock += 100; r.sch.tick();
}
CHECK(r.cam.triggers == 2);
}

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);
}