Compare commits

..

3 Commits

Author SHA1 Message Date
Peter Bochmann 28ff027950
Note what the thermal camera's SDK costs in CPU on tower 111
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:13:55 +02:00
Peter Bochmann 474e3cac8a
Fix what the thermal camera's first run on tower 111 showed
- The thermal frame was numbered camera 0 - label RGB, and the colour file of
  the same time overwritten - because Vimba counts no camera before open().
  The composite now takes each source's configured camera count, and two
  enabled cameras may no longer share a label.
- The SDK switches LC_NUMERIC to de_DE when loaded, which made "hotx":0,4336.
  Its calls now run under a guard that restores the locale, and the JSON's
  numbers no longer go through printf.
- The camera's frame counter is always 0: new frames are told by their time,
  so the stream no longer stops after the first one.
- The LUT is empty for the first frame: it is asked again on later ones.
- The SDK is libirbgrab_x86_64-linux.so in the ld cache.
- The null channel traces the CamEvent payload it would have sent.

Tested on tower 111 with the TarisIR mini, the motors mocked: cap_net_raw is
enough, RGB/TIR pairs with one timestamp, the TIR file decodes to the counts
its CamEvent describes, profile switches 0-1-0. 536/536 in the 22.04 container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:01:33 +02:00
Peter Bochmann 3d90af8490
Take a thermal picture beside the colour one at every waypoint
A TarisIR mini (driver = taris) is a second camera source behind a
CompositeCameraSource: the colour camera is asked first, then the thermal one
for its newest frame, and both frames carry the colour frame's timestamp.
Counts are saved 16-bit lossless under TIR/, and the CamEvent gains cal,
missed, tmax, tmed, hotx and hoty (docs/mqtt-api.md). The SDK is declared in
IrbgrabApi.h and loaded with dlopen, so no SDK file is in the repository; a
missing or unreachable camera stays offline (Health cams_offline) while the
scan goes on. scripts/fgc-setcap gives the binary cap_net_raw after a build.

Built and tested in an Ubuntu 22.04 container (mock, MQTT, Vimba+MQTT,
534/534), plus a mock run with RGB/TIR pairs. Not yet run against the camera.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:37:25 +02:00
46 changed files with 3199 additions and 75 deletions

View File

@ -94,6 +94,10 @@ add_library(fgc_core STATIC
src/core/ChannelHistory.cpp src/core/ChannelHistory.cpp
src/core/ChannelTopics.cpp src/core/ChannelTopics.cpp
src/core/RawImage.cpp src/core/RawImage.cpp
src/core/ThermalStats.cpp
src/camera/IrbgrabApi.cpp
src/camera/TarisCameraSource.cpp
src/camera/CompositeCameraSource.cpp
src/core/SettleRule.cpp src/core/SettleRule.cpp
src/core/ThermalGuard.cpp src/core/ThermalGuard.cpp
src/sensors/I2cBus.cpp src/sensors/I2cBus.cpp
@ -108,7 +112,7 @@ target_include_directories(fgc_core PUBLIC
${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/include
${CMAKE_SOURCE_DIR} # for the bundled ini.h ${CMAKE_SOURCE_DIR} # for the bundled ini.h
) )
target_link_libraries(fgc_core PUBLIC Threads::Threads) target_link_libraries(fgc_core PUBLIC Threads::Threads ${CMAKE_DL_LIBS})
# PUBLIC so the application and the tests both see the same build identity. # 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}") target_compile_definitions(fgc_core PUBLIC FGC_GIT_REV="${FGC_GIT_REV}")

View File

@ -70,7 +70,7 @@ demosaic = edge_aware
; generated human-readable record of the same settings; it configures nothing. ; generated human-readable record of the same settings; it configures nothing.
[Camera.rgb] [Camera.rgb]
enabled = true enabled = true
driver = vimba ; only vimba is implemented driver = vimba ; vimba (colour) or taris (thermal, below)
id = DEV_0000000000 ; USB DEV_ id, or a GigE IP id = DEV_0000000000 ; USB DEV_ id, or a GigE IP
label = RGB ; output subdir + CamEvent "cam" label = RGB ; output subdir + CamEvent "cam"
profile_dir = camera_profiles/rgb profile_dir = camera_profiles/rgb
@ -98,6 +98,27 @@ settle_rule = brightness
settle_wait_ms = 2500 settle_wait_ms = 2500
frame_timeout_ms = 5000 frame_timeout_ms = 5000
; --- A thermal camera (InfraTec TarisIR mini), beside the colour one ---------
; It takes its picture at the same waypoints and intervals, right after the
; colour camera, and both carry the same timestamp: RGB/<t>.jxl and TIR/<t>.jxl.
; The counts are saved 16-bit and always lossless (jxl_distance must stay 0);
; T[K] = count x 0.01 under profile 0, x 0.02 under profile 1, emissivity 1.
; Its driver, id (the serial) and interface belong in hardware.ini; [Hardware]
; cameras lists it. Needs the InfraTec SDK and licence on the tower, and
; cap_net_raw on the binary (scripts/fgc-setcap, which deploy.sh runs) - see
; docs/tower-installation.md. No settings XML: profile only names the timing.
;[Camera.tir]
;label = TIR
;calib_profile = 0 ; 0: -40..120 C (forest), 1: 0..600 C
;frame_rate_hz = 5 ; the camera free-runs at this, throttled
;max_frame_age_ms = 400 ; older is not kept; below the scan's settle wait
;rotation_deg = 0
;jxl_effort = 3
;
;[Camera.tir.standard] ; optional: without it 0 / 0, as it should be
;settle_wait_ms = 0 ; it takes the frame it already has
;frame_timeout_ms = 0
[Serial] [Serial]
; Motor-controller serial device and baud rate. ; Motor-controller serial device and baud rate.
device = /dev/ttyACM0 device = /dev/ttyACM0

View File

@ -53,7 +53,8 @@ env = sht41
; The legacy towers: off to 25 C, +20/255 per degree, full from 37.75 C, and full ; The legacy towers: off to 25 C, +20/255 per degree, full from 37.75 C, and full
; whenever the sensor cannot be read. Needs env = dht11 and [Pins] fan_pwm. ; whenever the sensor cannot be read. Needs env = dht11 and [Pins] fan_pwm.
;cooling = fan ;cooling = fan
; The [Camera.<name>] blocks expected to be present and found. ; The [Camera.<name>] blocks expected to be present and found ("rgb, tir" with
; the thermal camera).
cameras = rgb cameras = rgb
; How much bearing one picture covers, in degrees. Pan mode ('mode pan') cuts ; 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. ; its span into as many pictures as this takes. 6 until the lens is measured.
@ -64,6 +65,7 @@ pan_step_deg = 6
; (N5105) needs 2 workers for that. jxl_threads is libjxl's threads per frame; ; (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 ; 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). ; or be saving before the scan holds at its waypoint (each raw frame ~24 MB).
; A thermal camera adds a frame per picture (0.6 MB): raise it by 2.
save_workers = 1 save_workers = 1
;jxl_threads = 0 ;jxl_threads = 0
save_queue_max = 4 save_queue_max = 4
@ -247,3 +249,11 @@ has_encoder = true
; The serial of the physical camera fitted to this tower. A USB DEV_ id, or a ; The serial of the physical camera fitted to this tower. A USB DEV_ id, or a
; GigE IP address. ; GigE IP address.
id = DEV_1AB22C049CD1 id = DEV_1AB22C049CD1
; A thermal camera (TarisIR mini): its serial number, as the SDK's search finds
; it, and the network port it is cabled to (static address, no DHCP, MTU 1500;
; only used to say what is wrong when the camera is not found).
;[Camera.tir]
;driver = taris
;id = 2017744
;interface = enp1s0

View File

@ -308,6 +308,10 @@ ssh "$REMOTE_HOST" "$RESOLVE_CMAKE $CHECK_DEPS_CMD cd '$REMOTE_DIR' && \
GIT_REV='$GIT_REV' \"\$CMAKE\" -S . -B build $CMAKE_ARGS && \ GIT_REV='$GIT_REV' \"\$CMAKE\" -S . -B build $CMAKE_ARGS && \
\"\$CMAKE\" --build build $JOBS_FLAG" \"\$CMAKE\" --build build $JOBS_FLAG"
echo ">> build complete: $REMOTE_DIR/build/fire_gimbal_control" echo ">> build complete: $REMOTE_DIR/build/fire_gimbal_control"
# The rebuilt binary has lost its capabilities; a thermal camera needs
# cap_net_raw back (scripts/fgc-setcap). Without a thermal camera: nothing.
# shellcheck disable=SC2029
ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && scripts/fgc-setcap --if-thermal" || true
# A pushed config.ini was written on another machine: check that it loads on # A pushed config.ini was written on another machine: check that it loads on
# this one, with this tower's hardware.ini layered over it, before anyone runs it. # this one, with this tower's hardware.ini layered over it, before anyone runs it.

View File

@ -17,7 +17,7 @@ The design separates **policy** (the control logic) from **mechanism** (the I/O
|-----------|------|-------------| |-----------|------|-------------|
| `IMotorController` | `SerialMotorController` (Boost.Asio) | `MockMotorController` (simulated sweep) | | `IMotorController` | `SerialMotorController` (Boost.Asio) | `MockMotorController` (simulated sweep) |
| `IControlChannel` | `MqttControlChannel` (Paho) | `NullControlChannel` (no broker) | | `IControlChannel` | `MqttControlChannel` (Paho) | `NullControlChannel` (no broker) |
| `ICameraSource` | `VimbaCameraSource` (Vimba X) | `MockCameraSource` (synthetic frames) | | `ICameraSource` | `VimbaCameraSource` (Vimba X), `TarisCameraSource` (InfraTec TarisIR, thermal), both behind `CompositeCameraSource` | `MockCameraSource` (synthetic frames, also thermal counts) |
| `IImuSource` | `MtiImuSource` (Xsens MTi, Boost.Asio) | `MockImuSource` (synthetic orientation) | | `IImuSource` | `MtiImuSource` (Xsens MTi, Boost.Asio) | `MockImuSource` (synthetic orientation) |
| `IEnvSensor` | `Sht41EnvSensor` (SHT41 over I2C) | `MockEnvSensor` (synthetic temp/humidity) | | `IEnvSensor` | `Sht41EnvSensor` (SHT41 over I2C) | `MockEnvSensor` (synthetic temp/humidity) |
@ -54,6 +54,7 @@ thread so all geometry/state mutation stays single-threaded.
| Image worker | `ImagePipeline` | drains the frame queue: rotate → encode → write → publish | | Image worker | `ImagePipeline` | drains the frame queue: rotate → encode → write → publish |
| MQTT client | Paho (internal) | delivers callbacks; auto-reconnects (no busy-wait loop) | | MQTT client | Paho (internal) | delivers callbacks; auto-reconnects (no busy-wait loop) |
| Camera acquisition | Vimba X (internal) | free-runs at the mode's frame rate and delivers frames via the observer, which keeps the latest complete one | | Camera acquisition | Vimba X (internal) | free-runs at the mode's frame rate and delivers frames via the observer, which keeps the latest complete one |
| Thermal acquisition | `TarisCameraSource` worker | owns every SDK call: connects (and reconnects), polls the newest frame of counts, switches the calibration profile; `trigger()` only takes the kept frame |
Shared state is mutex-guarded: latest `MotorTelemetry` (serial), `RemoteCommand` queue (channel), the frame queue Shared state is mutex-guarded: latest `MotorTelemetry` (serial), `RemoteCommand` queue (channel), the frame queue
(pipeline), the console command queue, and the latest `UiSnapshot`. The `CaptureScheduler` runs only on the (pipeline), the console command queue, and the latest `UiSnapshot`. The `CaptureScheduler` runs only on the
@ -132,6 +133,23 @@ everything below is an implementation detail of the camera source. That is what
## Image capture ## Image capture
### Two cameras, one array: `CompositeCameraSource`
With a thermal camera the scheduler and the pipeline still see one
`ICameraSource`. The composite numbers the colour cameras first (camera 0 is
the one the keep rule reads) and the thermal one after them. Its `trigger()`
asks the colour source first; only if that delivered does it ask the thermal
source, which hands over its newest frame if it is no older than
`max_frame_age_ms` and otherwise says no at once. Every frame of one trigger
takes the colour frame's timestamp. The colour camera failing stops the run as
before; the thermal one failing never does (`offline()` says why, Health
`cams_offline` names it). Thermal frames are `Mono16` counts: the pipeline
rotates them, takes `tmax`/`tmed`/hot spot on the rotated image
(`ThermalStats.h`), and saves them 16-bit lossless whatever `jxl_distance` says.
The SDK is declared in `IrbgrabApi.h` (not copied: its licence forbids that) and
loaded with `dlopen`, so every build has the thermal code and its tests run
against a fake SDK.
### The model: free-run, then hold still ### The model: free-run, then hold still
The camera **runs continuously** at the mode's frame rate with its own auto-exposure and auto-gain in The camera **runs continuously** at the mode's frame rate with its own auto-exposure and auto-gain in

View File

@ -62,10 +62,10 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| `Network` | `mqtt_user` / `mqtt_pw` | string | — | MQTT credentials (see secrets below) | | `Network` | `mqtt_user` / `mqtt_pw` | string | — | MQTT credentials (see secrets below) |
| `Serial` | `device` | string | `/dev/ttyACM0` | Motor-controller serial device | | `Serial` | `device` | string | `/dev/ttyACM0` | Motor-controller serial device |
| `Serial` | `baud` | int | `115200` | Serial baud rate | | `Serial` | `baud` | int | `115200` | Serial baud rate |
| `Camera.<name>` | `id` | string | — | USB `DEV_` id or GigE IP. Required when enabled | | `Camera.<name>` | `id` | string | — | `vimba`: USB `DEV_` id or GigE IP; `taris`: the camera's serial number. Required when enabled |
| `Camera.<name>` | `driver` | `vimba` | `vimba` | Backend. Only `vimba` is implemented | | `Camera.<name>` | `driver` | `vimba` \| `taris` | `vimba` | Backend: Allied Vision Vimba X (colour), or InfraTec TarisIR (thermal, [below](#thermal-camera-driver--taris)). Colour cameras always come first in the camera order, whatever the block names |
| `Camera.<name>` | `label` | string | the block name | Output subdirectory and `CamEvent` `cam` field | | `Camera.<name>` | `label` | string | the block name | Output subdirectory and `CamEvent` `cam` field |
| `Camera.<name>` | `profile_dir` | string | — | Directory of profile XMLs; a profile named X is `<profile_dir>/X.xml`. A relative path resolves against the directory holding the `config.ini` in use | | `Camera.<name>` | `profile_dir` | string | — | `vimba` only. Directory of profile XMLs; a profile named X is `<profile_dir>/X.xml`. A relative path resolves against the directory holding the `config.ini` in use |
| `Camera.<name>` | `profile` | string | `standard` | Profile to start in. Switch live with `set camera profile [<camera>] <name>` | | `Camera.<name>` | `profile` | string | `standard` | Profile to start in. Switch live with `set camera profile [<camera>] <name>` |
| `Camera.<name>` | `enabled` | bool | `true` | A disabled camera is not opened and does not affect the scan timing | | `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>` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to that camera's saved images |
@ -75,6 +75,11 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| `Camera` | `jxl_distance` / `jxl_effort` | double ≥ 0 / int 1..9 | `0.8` / `4` | Encoder defaults inherited by every camera block | | `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. Distance 0 is truly lossless | | `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 | | `Camera` / `Camera.<name>` | `demosaic` | `edge_aware` \| `bilinear` | `edge_aware` | How raw profiles (`BayerRG8`, `BayerRG12p`) are converted to colour on the tower |
| `Camera.<name>` (`taris`) | `calib_profile` | int ≥ 0 | `0` | Calibration profile set on connect: 0 = -40..120 °C at 0.01 K/count, 1 = 0..600 °C at 0.02 K/count. Switch live with `camera calib <n>` |
| `Camera.<name>` (`taris`) | `frame_rate_hz` | 0 < x ≤ 50 | `5` | The rate the camera free-runs at |
| `Camera.<name>` (`taris`) | `max_frame_age_ms` | int > 0 | `400` | A frame older than this when the picture is taken is not kept: that picture has no thermal image. Keep it below the scan's settle wait, or the frame can predate standstill (warned at startup) |
| `Camera.<name>` (`taris`) | `library` | string | `libirbgrab_x86_64-linux.so` | The InfraTec SDK, found through the ld cache |
| `Camera.<name>` (`taris`, hardware.ini) | `interface` | string | — | The network port the camera is cabled to; only named in the message when it is not found |
| `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) | `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) | `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) | | `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) |
@ -1054,3 +1059,29 @@ grid (front to back, repeating); in **pan** mode it walks the pan's headings lef
pitch, then starts again from the left edge (see [mqtt-api.md](mqtt-api.md#modes-auto-pan-manual)). Telemetry arrives as pitch, then starts again from the left edge (see [mqtt-api.md](mqtt-api.md#modes-auto-pan-manual)). Telemetry arrives as
firmware `ST` lines (per-axis state + encoder counts), parsed by firmware `ST` lines (per-axis state + encoder counts), parsed by
[TelemetryParser](../src/core/TelemetryParser.cpp). [TelemetryParser](../src/core/TelemetryParser.cpp).
## Thermal camera (`driver = taris`)
A TarisIR mini beside the colour camera takes its picture at the same waypoints
and intervals: the colour camera is asked first, and only once it has delivered
is the thermal one asked for its newest frame. Both frames carry the colour
frame's timestamp, so `RGB/<t>.jxl` and `TIR/<t>.jxl` are a pair (and so are
their CamEvents, see [mqtt-api.md](mqtt-api.md#thermal-camera-cam-tir)).
- **Best effort.** A thermal camera that is missing, unlicensed or has no fresh
frame costs that picture its thermal image, nothing more: the scan goes on, the
camera reconnects by itself, and Health `cams_offline` names it meanwhile.
- **Always lossless.** The counts are measurements; `jxl_distance` must be 0
(the default for this driver) and the loader refuses anything else.
- **No settings XML.** The camera is set up from its keys; `profile` only picks a
`[Camera.<name>.<profile>]` timing block, which it does not need: without one
its wait is 0 and the colour camera's settle wait alone sets the pace. The
frame it hands over is at most `max_frame_age_ms` old, so that must stay below
the colour camera's `settle_wait_ms`.
- **Kept grabbing**: from start to exit, also in manual mode. A microbolometer
that is restarted begins with zero frames and a fresh NUC.
- **Split between the files** like any camera: `driver`, `id` and `interface`
in hardware.ini (with `tir` in `[Hardware] cameras`), the rest in config.ini.
`save_queue_max` counts frames, so raise it by 2.
- The binary needs `cap_net_raw` (the SDK uses a raw socket):
[tower-installation.md](tower-installation.md#phase-2b---thermal-camera).

View File

@ -166,8 +166,22 @@ scripts for this are worth keeping; note that wrapping them in `timeout` SIGTERM
key features (`AcquisitionFrameRate`, `Width`, `PixelFormat`) would turn a silent misconfiguration into key features (`AcquisitionFrameRate`, `Width`, `PixelFormat`) would turn a silent misconfiguration into
an error. an error.
- Graceful shutdown on SIGINT (currently exit via `exit`/Ctrl-D; a pending `getline` can delay shutdown). - Graceful shutdown on SIGINT (currently exit via `exit`/Ctrl-D; a pending `getline` can delay shutdown).
- Support a second camera backend. `driver` is parsed and validated but only `vimba` is accepted, so a - ~~Support a second camera backend.~~ Done for the thermal camera (`driver = taris`,
thermal or NIR unit needs a new `ICameraSource` implementation behind that key. `TarisCameraSource`, behind `CompositeCameraSource`), tested with the TarisIR mini on tower 111 on
2026-10-14 (docs/tower-installation.md, Phase 2b). Not yet tested: a pulled camera cable during a scan
(the reconnect is only unit-tested), and a long unattended run. A NIR unit would be a third driver
behind the same key.
- The InfraTec SDK costs about a quarter of one core on the 3 Delta even between pictures: three threads
it starts in `dll_init` and one per connection poll the raw socket in `nanosleep` loops (measured on
tower 111, 2026-10-14). The frame rate does not change it. In the first minutes after deploying, the
CPU guard slowed the scan once (86 °C, back to normal after 2 min). Watch Health `cpu_c` over a warm
day before a tower without air flow gets a thermal camera.
- Tower 111's colour profile does not apply: every start since 2026-10-07 logs `applying profile 'standard'
from .../rgb/standard.xml failed (error -39)`, and the camera runs on whatever it had. Unrelated to the
thermal camera; seen again while testing it.
- Per-channel boresight: `[Motor]` heading and tilt calibration belong to the tower, but the thermal camera
sits on the gimbal with its own small offset against the colour one. `hotx`/`hoty` are therefore in the
thermal image's own frame; projecting them onto terrain needs a per-camera offset nobody has measured yet.
- Reintroduce optional image upload to the ground station, config-driven (the old hardcoded NFS/SMB upload was - Reintroduce optional image upload to the ground station, config-driven (the old hardcoded NFS/SMB upload was
removed). removed).
- **Calibration persistence needs the TUI.** `gimbal pitch setup` and `gimbal heading fit` are always - **Calibration persistence needs the TUI.** `gimbal pitch setup` and `gimbal heading fit` are always

View File

@ -95,7 +95,7 @@ Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
| Field | Type | Meaning | | Field | Type | Meaning |
|-------|------|---------| |-------|------|---------|
| `fwt` | string | Tower name (`config.ini` `tower_name`) | | `fwt` | string | Tower name (`config.ini` `tower_name`) |
| `cam` | string | Camera label: `RGB`, `ACR`, or `NIR` (by camera index) | | `cam` | string | Camera label (`[Camera.<name>] label`): `RGB`, `TIR`, `ACR` or `NIR` |
| `hdg` | int | Gimbal **true bearing** × 10 (0 = north) at capture time, from the **yaw encoder** | | `hdg` | int | Gimbal **true bearing** × 10 (0 = north) at capture time, from the **yaw encoder** |
| `pit` | int | Gimbal pitch elevation **× 10** at capture time, from the **IMU** — elevation relative to gravity, i.e. true pointing angle (fallbacks below) | | `pit` | int | Gimbal pitch elevation **× 10** at capture time, from the **IMU** — elevation relative to gravity, i.e. true pointing angle (fallbacks below) |
| `time` | int | Capture timestamp, Unix epoch **milliseconds** (matches the `.jxl` filename) | | `time` | int | Capture timestamp, Unix epoch **milliseconds** (matches the `.jxl` filename) |
@ -135,6 +135,40 @@ Both `hdg` and `pit` are sampled **when the frame arrives**, not when its JPEG X
finishes, so a slow encode or a backed-up queue cannot attribute a later pose to earlier finishes, so a slow encode or a backed-up queue cannot attribute a later pose to earlier
pixels. pixels.
#### Thermal camera (`cam: "TIR"`)
A thermal camera's events carry six more fields; a colour camera's never do, and
their payload is unchanged.
```json
{ "fwt":"ExampleTower", "cam":"TIR", "hdg":1373, "pit":300, "time":1719312345678,
"cal":0, "missed":0, "tmax":3015, "tmed":187, "hotx":0.7359, "hoty":0.5833 }
```
| Field | Type | Meaning |
|-------|------|---------|
| `cal` | int | Calibration profile the image was taken on. **Required to read the file**: it sets the counts' scale |
| `missed` | int | GigE packets the SDK lost for this frame (transfer quality) |
| `tmax` | int/null | Hottest pixel, **apparent** temperature in tenths of a °C |
| `tmed` | int/null | Median of the image, tenths of a °C (the anchor for a display window) |
| `hotx`, `hoty` | number/null | Where the hottest pixel is: its centre, 0..1 across and down the image **as stored** (after `rotation_deg`), as for detection boxes |
- **Same `time` as the colour picture.** The thermal camera is asked right after
the colour one, at the same waypoint, and its event and file take the colour
frame's timestamp: `RGB/<time>.jxl` and `TIR/<time>.jxl` are one pair. A
picture the thermal camera had no fresh frame for has no `TIR` event.
- **The file holds counts**, 16-bit single channel, lossless:
`T[K] = count × 0.01` under `cal` 0 (`FF (-40-120C)`), `× 0.02` under `cal` 1
(`FF (0-600C)`), valid for `count ≤ 65280` (above it the camera's table wraps;
0 is never a temperature).
- **Apparent temperature**: emissivity and path transmission are taken as 1.
Vegetation (ε ≈ 0.97) over kilometres of air reads a few kelvin off its true
temperature; the counts keep every correction possible later. `tmax`/`tmed`
use the same convention, so they are for triage and display, not for a
reported fire temperature.
- `null` in the four statistics means the frame had no valid pixel; `cal` and
`missed` are still set.
CamEvent and Health leave through a small queue on their own thread, so a slow link CamEvent and Health leave through a small queue on their own thread, so a slow link
never holds an image save or the control loop. If 64 are waiting, the oldest is never holds an image save or the control loop. If 64 are waiting, the oldest is
dropped (and shows as dropped in the connectivity view). dropped (and shows as dropped in the connectivity view).
@ -149,8 +183,8 @@ The tower computer's heat and load, and whether the scan is slowed or paused for
{"fwt":"t121","state":"paused","reason":"CPU at 93 °C for 20 s","guard":true, {"fwt":"t121","state":"paused","reason":"CPU at 93 °C for 20 s","guard":true,
"cpu_c":93.0,"cpu_mhz":1188,"cpu_busy_pct":41,"throttling":true, "cpu_c":93.0,"cpu_mhz":1188,"cpu_busy_pct":41,"throttling":true,
"throttle_events_last_min":14,"enclosure_c":48.2,"saved_last_min":0, "throttle_events_last_min":14,"enclosure_c":48.2,"saved_last_min":0,
"save_ms_mean":null,"save_ms_max":null,"held_s":0,"interval_s":10.0, "save_ms_mean":null,"save_ms_max":null,"cams_offline":"","held_s":0,
"time":1759917600000} "interval_s":10.0,"time":1759917600000}
``` ```
| Field | Type | Meaning | | Field | Type | Meaning |
@ -164,7 +198,8 @@ The tower computer's heat and load, and whether the scan is slowed or paused for
| `throttling` | bool | The CPU is slowing itself (kernel counters, or a low clock while busy) | | `throttling` | bool | The CPU is slowing itself (kernel counters, or a low clock while busy) |
| `throttle_events_last_min` | int | Kernel throttle events counted in the last minute | | `throttle_events_last_min` | int | Kernel throttle events counted in the last minute |
| `enclosure_c` | number/null | The ambient sensor (SHT41/DHT11), when there is one | | `enclosure_c` | number/null | The ambient sensor (SHT41/DHT11), when there is one |
| `saved_last_min`, `save_ms_mean`, `save_ms_max` | int, number/null | Pictures saved in the last minute and how long each took | | `saved_last_min`, `save_ms_mean`, `save_ms_max` | int, number/null | Pictures saved in the last minute and how long each took (every camera's: with a thermal camera, two per waypoint) |
| `cams_offline` | string | Labels of cameras not delivering, comma-separated (`"TIR"`); `""` when all are. Only a camera the scan can do without - the thermal one - is ever listed: it reconnects by itself while the scan goes on |
| `held_s` | int | How long the scan has been waiting for saving to catch up | | `held_s` | int | How long the scan has been waiting for saving to catch up |
| `interval_s` | number | Time between pictures now | | `interval_s` | number | Time between pictures now |
| `time` | int | Unix epoch ms | | `time` | int | Unix epoch ms |

View File

@ -884,6 +884,77 @@ Then `mode auto`, and confirm `.jxl` files appear under the configured output di
> running an older binary, export the profiles (step 8) first or it will refuse > running an older binary, export the profiles (step 8) first or it will refuse
> with `no camera profiles (*.xml)`. > with `no camera profiles (*.xml)`.
### Phase 2b - thermal camera
Only for a tower with an InfraTec TarisIR mini (tower 111 since 2026-10). The
program runs as the tower's user; the SDK needs three things from the system.
1. **SDK and licence.** Packages `infratec-sdk-irbgrab` and
`infratec-itcamtarisirmini` (amd64), then the licence for the camera's serial
via the vendor's `copycustomerdata.sh` into `/var/lib/infratec/.license/`
(`root:infratec`, `rw-rw----`). **Repeat after every OS update.** Keep the
source files on the tower (`~/InfraTec/LINUX/` on 111) so it can be re-run.
The tower's user must be in group `infratec` (`id` shows it; `sudo usermod -aG
infratec ggs` and log in again otherwise). The library must be in the ld
cache: `ldconfig -p | grep irbgrab`.
2. **Network.** Camera and port directly cabled, static address, **no DHCP**
(`dhcp4: false` in netplan: DHCP on a link without a server keeps failing and
takes the static address with it), **MTU 1500** - the camera does no jumbo
frames. The SDK finds the camera by serial and MAC on raw Ethernet; the IP
only matters to ordinary GigE Vision tools. On 111: `enp1s0`, 192.168.0.111,
camera 192.168.0.200.
3. **`cap_net_raw` on the binary.** The SDK opens a raw `AF_PACKET` socket.
Rather than running the program as root, the binary gets this one
capability. A rebuild drops it, so `deploy.sh` and `install-tower` run
`scripts/fgc-setcap --if-thermal` after every build. Once per tower, let
deploy do that without a password:
```bash
scripts/fgc-setcap --install-sudoers # /etc/sudoers.d/fire-gimbal-control-setcap
scripts/fgc-setcap && getcap build/fire_gimbal_control
```
A capability binary runs in the loader's secure mode (`LD_*` ignored, no core
dumps): check once that Vimba X still opens the colour camera, and that
`scripts/fgc status` and `stop` still find the process.
Then hardware.ini (`driver = taris`, `id = <serial>`, `interface = enp1s0`,
`cameras = rgb, tir`) and config.ini (`label = TIR`, see
[configuration.md](configuration.md#thermal-camera-driver--taris)), and a run
with the real cameras but no motors:
```bash
./build/fire_gimbal_control --mock-serial --mock-env --no-mqtt --no-tui
```
```
[INFO ] IRBGRAB SDK 4.3.44
[INFO ] thermal camera connected via SN:2017744|...|192.168.0.200#0
[INFO ] thermal camera: profile 0, 0.01 K per count
```
`snapshot` then writes `RGB/<t>.jxl` and `TIR/<t>.jxl` with the same `<t>`.
`camera calib` reports the profile. If the camera stays offline the log says
why, once per change - `no cap_net_raw`, `not found (found: ...)`, the port's
state and MTU. A `0x80000017` that keeps coming is the licence before it is the
NUC.
Measured on tower 111 (2026-10-14, SDK 4.3.44, SN 2017744):
- `cap_net_raw` alone is enough; no root, no `cap_net_admin`. Vimba X still
opens the colour camera from the capability binary.
- Connecting takes about 15 s (SDK init and search); the scan does not wait.
- `Framerate_Hz` is settable. The LUT is empty for the first frame after
connecting and right from then on: 0.0099998 K/count (profile 0), 0.0199995
(profile 1).
- A profile switch takes about 3 s; frames keep the old scale until
`Calib_ChangeInProgress` reaches 0, and the program takes none meanwhile.
- The camera's frame counter (`ImageNum`) is always 0; `MissedPackets` was
always 0 on the direct link.
- The SDK switches the process's C locale to `LC_NUMERIC=de_DE` when it is
loaded. The program puts it back after every SDK call and writes its JSON
without the C library's number formatting.
### Phase 3 - the broker ### Phase 3 - the broker
Set `[Features] enable_mqtt = true` and point `zkms_server_ip` at the broker. Set `[Features] enable_mqtt = true` and point `zkms_server_ip` at the broker.

View File

@ -0,0 +1,72 @@
#pragma once
#include "fgc/ICameraSource.h"
#include <memory>
#include <vector>
namespace fgc {
// Several camera sources behind one, so the scheduler and the pipeline keep
// seeing a single camera array: the colour cameras through Vimba and the
// thermal one through its own SDK take their pictures at the same waypoints
// and intervals without either knowing about the other.
//
// The first child leads. Its cameras come first in the index order (camera 0
// is its first camera, which the keep rule reads), and its trigger() decides
// whether a picture was taken: only once it has delivered are the others
// asked, so a retry while the leader waits never yields a second thermal
// frame for the same waypoint. The others are best effort - a follower with
// no fresh frame costs that picture its thermal image, nothing more.
//
// Every frame of one trigger carries the leader's timestamp, so the pair is
// RGB/<t>.jxl and TIR/<t>.jxl with the same <t> in both CamEvents.
//
// Indices are global: child k's camera i is offset(k) + i. Calls about one
// camera go to the child that has it.
//
// The offsets come from `counts`, how many cameras each child is configured
// with, not from cameraCount(): VimbaCameraSource counts what it has opened,
// which is 0 before open(), and a thermal frame numbered from that landed as
// camera 0 - the colour camera's label and file (seen on tower 111). Without
// counts, each child's cameraCount() at construction is taken.
class CompositeCameraSource : public ICameraSource {
public:
explicit CompositeCameraSource(std::vector<std::unique_ptr<ICameraSource>> children,
std::vector<int> counts = {});
void open() override;
void close() override;
void start() override;
void stop() override;
bool trigger() override;
bool setProfile(int camera_index, const CameraProfile& profile) override;
void setFrameCallback(FrameCallback cb) 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;
int cameraCount() const override;
std::vector<CameraDeviceInfo> deviceInfo() override;
std::string offline(int camera_index) 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;
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;
size_t childCount() const { return children_.size(); }
ICameraSource& child(size_t k) { return *children_[k]; }
private:
// The child holding a global index and its index there; nullptr if none.
ICameraSource* route(int camera_index, int& local) const;
std::vector<std::unique_ptr<ICameraSource>> children_;
std::vector<int> counts_; // cameras each child is configured with
std::vector<int> offsets_; // global index of each child's camera 0
FrameCallback callback_;
bool in_trigger_ = false;
long long shot_ts_ = 0; // the leader's frame time, during a trigger
};
} // namespace fgc

View File

@ -199,11 +199,14 @@ struct CameraProfileTiming {
struct CameraSpec { struct CameraSpec {
std::string name; // the block suffix, e.g. "rgb" std::string name; // the block suffix, e.g. "rgb"
bool enabled = true; bool enabled = true;
std::string driver = "vimba"; // only "vimba" is implemented // "vimba" (Allied Vision, colour) or "taris" (InfraTec TarisIR, thermal).
std::string id; // USB DEV_ id or GigE IP std::string driver = "vimba";
std::string id; // vimba: USB DEV_ id or GigE IP; taris: serial number
std::string label; // output subdir + CamEvent "cam"; defaults to name std::string label; // output subdir + CamEvent "cam"; defaults to name
std::string profile_dir; // relative => resolved next to config.ini std::string profile_dir; // vimba only; relative => resolved next to config.ini
std::string profile = "standard"; // which profile to start in // Which profile to start in. For vimba a settings XML in profile_dir; for
// taris only the name of a timing block, the camera having no settings file.
std::string profile = "standard";
int rotation_deg = 0; // clockwise; 0/90/180/270 int rotation_deg = 0; // clockwise; 0/90/180/270
// Encoding, defaulted from [Camera] and overridable per camera. // Encoding, defaulted from [Camera] and overridable per camera.
@ -212,6 +215,19 @@ struct CameraSpec {
// De-Bayering for raw profiles (BayerRG8/12p): "edge_aware" or "bilinear". // De-Bayering for raw profiles (BayerRG8/12p): "edge_aware" or "bilinear".
std::string demosaic = "edge_aware"; std::string demosaic = "edge_aware";
// ---- driver = taris only (TarisCameraSource.h) ----
// Operation (config.ini):
int calib_profile = 0; // camera calibration profile; sets the counts' scale
double frame_rate_hz = 5.0; // the camera free-runs at this rate, throttled
// A frame older than this at capture is not kept. Must stay below the scan's
// settle wait, or the frame may have been taken while the gimbal still moved.
int max_frame_age_ms = 400;
std::string library = "libirbgrab_x86_64-linux.so"; // the SDK, found through the ld cache
// Mounting (hardware.ini): the camera's network port, for diagnostics only.
std::string interface;
bool thermal() const { return driver == "taris"; }
std::map<std::string, CameraProfileTiming> timing; // by profile name std::map<std::string, CameraProfileTiming> timing; // by profile name
// Timing for `profile`, or the defaults when the block does not name it. // Timing for `profile`, or the defaults when the block does not name it.
@ -219,7 +235,10 @@ struct CameraSpec {
}; };
struct CameraConfig { struct CameraConfig {
std::vector<CameraSpec> cameras; // in config order; empty is a config error // Colour (vimba) cameras first, then thermal ones, each group in block-name
// order: camera 0 is the one the keep rule and the scan pace follow, and
// that must be a colour camera. Empty is a config error.
std::vector<CameraSpec> cameras;
// [Camera] defaults, inherited by each block unless it overrides them. // [Camera] defaults, inherited by each block unless it overrides them.
double jxl_distance = 0.8; double jxl_distance = 0.8;
int jxl_effort = 4; int jxl_effort = 4;

View File

@ -3,9 +3,11 @@
#include "fgc/CameraProfile.h" #include "fgc/CameraProfile.h"
#include "fgc/RawImage.h" #include "fgc/RawImage.h"
#include "fgc/SettleRule.h" #include "fgc/SettleRule.h"
#include "fgc/ThermalStats.h"
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
@ -32,6 +34,8 @@ struct Frame {
// Sampled from `data` on arrival (see RawImage.h); invalid when the layout // Sampled from `data` on arrival (see RawImage.h); invalid when the layout
// is Unknown. // is Unknown.
BrightnessSample brightness; BrightnessSample brightness;
// Set by a thermal camera only: how to read its Mono16 counts.
std::optional<ThermalFrameInfo> thermal;
}; };
// Live per-camera identity, sensor telemetry, and acquisition stats, for the // Live per-camera identity, sensor telemetry, and acquisition stats, for the
@ -113,6 +117,12 @@ public:
// Default: none. Implementations may refresh a throttled cache, so non-const. // Default: none. Implementations may refresh a throttled cache, so non-const.
virtual std::vector<CameraDeviceInfo> deviceInfo() { return {}; } virtual std::vector<CameraDeviceInfo> deviceInfo() { return {}; }
// Why one camera delivers nothing right now ("not found on the network"),
// or empty while it works. For a camera the scan can do without (the
// thermal one), which reconnects by itself rather than stopping the run.
// Default: always working.
virtual std::string offline(int /*camera_index*/) { return {}; }
// ---- For `test camera` only ------------------------------------------------ // ---- For `test camera` only ------------------------------------------------
// The camera's configuration otherwise changes through setProfile() and // The camera's configuration otherwise changes through setProfile() and
// nothing else; these let the self-test drive a camera to known extremes and // nothing else; these let the self-test drive a camera to known extremes and

View File

@ -1,17 +1,32 @@
#pragma once #pragma once
#include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
namespace fgc { namespace fgc {
// What a thermal camera's CamEvent carries beyond the common fields (see
// docs/mqtt-api.md). Temperatures are apparent (emissivity 1), in tenths of a
// degree Celsius; the hot spot is normalised 0..1 across the stored image.
struct ThermalEventFields {
int cal = 0; // calibration profile: says how to read the counts
int missed = 0; // GigE packets lost for this frame
bool stats_ok = false; // false: no valid pixel, the four below go out as null
int tmax_decic = 0;
int tmed_decic = 0;
double hotx = 0.0;
double hoty = 0.0;
};
// Notification published after an image is captured and saved. // Notification published after an image is captured and saved.
struct CamEvent { struct CamEvent {
std::string tower; // tower / FWT name std::string tower; // tower / FWT name
std::string camera; // "RGB" / "ACR" / "NIR" std::string camera; // "RGB" / "TIR" / "ACR" / "NIR"
int heading_decideg = 0; // yaw heading * 10 (one decimal as integer) int heading_decideg = 0; // yaw heading * 10 (one decimal as integer)
int pitch_decideg = 0; // pitch elevation * 10 (one decimal as integer) int pitch_decideg = 0; // pitch elevation * 10 (one decimal as integer)
long long timestamp_ms = 0; // Unix epoch ms; matches the image filename long long timestamp_ms = 0; // Unix epoch ms; matches the image filename
std::optional<ThermalEventFields> thermal; // thermal cameras only
}; };
// The tower computer's health, on GGS/FWT/<tower>/Health (retained): the CPU // The tower computer's health, on GGS/FWT/<tower>/Health (retained): the CPU
@ -34,6 +49,7 @@ struct HealthReport {
int save_count_last_min = 0; int save_count_last_min = 0;
double save_ms_mean = 0.0; double save_ms_mean = 0.0;
double save_ms_max = 0.0; double save_ms_max = 0.0;
std::string cams_offline; // labels of cameras not delivering, comma-separated
long long held_s = 0; // the scan waiting for saving, seconds long long held_s = 0; // the scan waiting for saving, seconds
double interval_s = 0.0; // the time between pictures now double interval_s = 0.0; // the time between pictures now
long long timestamp_ms = 0; // Unix epoch ms long long timestamp_ms = 0; // Unix epoch ms

View File

@ -26,7 +26,9 @@ struct Orientation {
// Camera.rotation_deg, encode to JPEG XL, write to // Camera.rotation_deg, encode to JPEG XL, write to
// <output_dir>/<label>/<timestamp>.jxl, and publish a CamEvent. Raw frames // <output_dir>/<label>/<timestamp>.jxl, and publish a CamEvent. Raw frames
// (BayerRG8, BayerRG12p) are de-Bayered first and saved at their own bit depth, // (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 // so a 12-bit frame becomes a 12-bit file. Thermal counts (Mono16) are saved
// as they are, 16-bit and always lossless, and their CamEvent carries the
// frame's temperature figures (ThermalStats.h). Runs background workers so encoding
// never blocks acquisition. Split out of the old VimbaHandler::SaveImage so any // never blocks acquisition. Split out of the old VimbaHandler::SaveImage so any
// camera source (real or mock) can feed it. // camera source (real or mock) can feed it.
class ImagePipeline { class ImagePipeline {
@ -89,6 +91,8 @@ public:
// Last CamEvent published (camera label, heading/pitch, timestamp), for the // Last CamEvent published (camera label, heading/pitch, timestamp), for the
// UI's camera panel. Thread-safe; nullopt until the first capture is saved. // UI's camera panel. Thread-safe; nullopt until the first capture is saved.
std::optional<CamEvent> lastEvent() const; std::optional<CamEvent> lastEvent() const;
// The same for the last thermal picture only (its event carries `thermal`).
std::optional<CamEvent> lastThermalEvent() const;
// Number of images successfully written this session (for the camera view). // Number of images successfully written this session (for the camera view).
long long imagesSaved() const { return saved_.load(); } long long imagesSaved() const { return saved_.load(); }
@ -117,8 +121,10 @@ private:
void run(); void run();
void process(const QueuedFrame& queued); void process(const QueuedFrame& queued);
// Encode and write one image; false (logged) on failure. // Encode and write one image; false (logged) on failure. For a thermal
bool save(const Frame& frame, const CameraOutput& out, const std::string& file); // frame, `stats` receives its figures, taken after rotation.
bool save(const Frame& frame, const CameraOutput& out, const std::string& file,
ThermalStats* stats = nullptr);
// Output settings for a camera index, or sane defaults if the index is out of // Output settings for a camera index, or sane defaults if the index is out of
// range (a source delivering more cameras than were configured). // range (a source delivering more cameras than were configured).
const CameraOutput& outputFor(int cam_id) const; const CameraOutput& outputFor(int cam_id) const;
@ -136,6 +142,7 @@ private:
mutable std::mutex last_event_mutex_; mutable std::mutex last_event_mutex_;
std::optional<CamEvent> last_event_; std::optional<CamEvent> last_event_;
std::optional<CamEvent> last_thermal_event_;
std::atomic<long long> saved_{0}; // images successfully written this session std::atomic<long long> saved_{0}; // images successfully written this session
// (steady ms when done, save ms) of each picture saved in the last minute. // (steady ms when done, save ms) of each picture saved in the last minute.

134
include/fgc/IrbgrabApi.h Normal file
View File

@ -0,0 +1,134 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>
// The few calls of InfraTec's IRBGRAB SDK (libirbgrab, v4) that the thermal
// camera needs, declared here rather than taken from the SDK's own headers:
// the SDK is licensed per tower and may not be copied into this repository or
// a build image. Everything below was established against the installed SDK
// (4.3.44) and a TarisIR mini - see TarisIR-Integration.md in the workspace.
//
// The library is loaded at run time (dlopen), so the program builds and its
// tests run on machines that have never seen the SDK; a tower without it
// simply has no thermal camera.
namespace fgc::irbgrab {
using Handle = size_t; // the SDK's handles are pointer-sized integers
using Ret = uint32_t;
// Return codes: a type in the top bits, a code below.
inline constexpr Ret kTypeOk = 0x10000000;
inline constexpr Ret kTypeStatus = 0x20000000;
inline constexpr Ret kTypeErr = 0x80000000;
inline constexpr Ret kSuccess = kTypeOk | 0x01;
inline constexpr Ret kErrNotSupported = kTypeErr | 0x02;
inline constexpr Ret kErrNotFound = kTypeErr | 0x03;
// Means both "NUC in progress" and "invalid licence". With the licence
// installed correctly it was never seen in testing, so when it piles up the
// licence is the first suspect.
inline constexpr Ret kErrNucOrLicence = 0x80000017;
inline bool ok(Ret r) { return r == kSuccess; }
// Fixed text buffers the SDK fills.
inline constexpr int kCharBuffer = 1024;
// What dev_getdata hands out.
enum MemObject : int32_t {
kMemBitmap32 = 1,
kMemIrDigitFrame = 2, // uint16 counts: what is archived
kMemTemperatures = 3, // float32 Kelvin: the counts through the LUT, nothing more
};
// Parameter ids (dev_getparam / dev_setparam).
namespace param {
inline constexpr uint32_t kCalibMaxFrameRate = 117;
inline constexpr uint32_t kCalibChangeInProgress = 118;
inline constexpr uint32_t kCalibCount = 119;
inline constexpr uint32_t kCalibActive = 120;
inline constexpr uint32_t kCalibName = 121;
inline constexpr uint32_t kPollingEnable = 184; // getdata without a callback
inline constexpr uint32_t kSearchString = 202;
inline constexpr uint32_t kCameraName = 203;
inline constexpr uint32_t kCameraSerialNum = 211;
inline constexpr uint32_t kCameraTempHousing = 217; // Kelvin
inline constexpr uint32_t kFramerateHz = 240;
inline constexpr uint32_t kFrameWidth = 294;
inline constexpr uint32_t kFrameHeight = 295;
} // namespace param
#pragma pack(push, 1)
struct String {
char* text;
int32_t len;
};
struct IdxString {
int32_t index;
String value;
};
struct IdxInt32 {
int32_t index;
int32_t value;
};
// Per-frame information (mem_getinfo). struct_size must be set before the call.
struct MemInfo {
int32_t struct_size;
int32_t triggered;
double timestamp_ms; // since the grab started
int64_t image_num;
int32_t missed_packets;
};
#pragma pack(pop)
// Data type tokens: base type | extension | size of what the pointer points at.
inline constexpr uint32_t kBaseInt32 = 0x40000000;
inline constexpr uint32_t kBaseInt64 = 0x50000000;
inline constexpr uint32_t kBaseFloat32 = 0xA0000000;
inline constexpr uint32_t kBaseString = 0xF0000000;
inline constexpr uint32_t kExIndex = 0x01000000;
inline constexpr uint32_t kTypeInt32 = kBaseInt32 | sizeof(int32_t);
inline constexpr uint32_t kTypeInt64 = kBaseInt64 | sizeof(int64_t);
inline constexpr uint32_t kTypeSingle = kBaseFloat32 | sizeof(float);
inline constexpr uint32_t kTypeString = kBaseString | sizeof(String);
inline constexpr uint32_t kTypeIdxString = kExIndex | kBaseString | sizeof(IdxString);
inline constexpr uint32_t kTypeIdxInt32 = kExIndex | kBaseInt32 | sizeof(IdxInt32);
// The entry points, as function pointers so a test can stand in for the SDK.
struct Api {
Ret (*dll_init)() = nullptr;
void (*dll_uninit)() = nullptr;
Ret (*dll_version)(char* buf, int len) = nullptr;
Ret (*dll_devicetypenames)(char* buf, int len) = nullptr; // ';'-separated
Ret (*dev_create)(Handle* dev, int32_t type, char* ini) = nullptr;
Ret (*dev_free)(Handle* dev) = nullptr;
Ret (*dev_search)(Handle dev, int32_t* count) = nullptr;
Ret (*dev_connect)(Handle dev, char* search) = nullptr;
Ret (*dev_disconnect)(Handle dev) = nullptr;
Ret (*dev_startgrab)(Handle dev, int32_t stream) = nullptr;
Ret (*dev_stopgrab)(Handle dev, int32_t stream) = nullptr;
Ret (*dev_getparam)(Handle dev, uint32_t what, void* data, uint32_t type) = nullptr;
Ret (*dev_setparam)(Handle dev, uint32_t what, void* data, uint32_t type) = nullptr;
Ret (*dev_getdata)(Handle dev, int32_t what, Handle* mem) = nullptr;
Ret (*mem_free)(Handle* mem) = nullptr;
Ret (*mem_getdimension)(Handle mem, uint32_t* w, uint32_t* h, uint32_t* type) = nullptr;
Ret (*mem_getdataptr)(Handle mem, Handle* data, int32_t* bytes) = nullptr;
Ret (*mem_getinfo)(Handle mem, void* info) = nullptr;
// The counts-to-Kelvin table (65536 floats) for the given emissivity,
// transmission, environment and path temperature.
Ret (*mem_getdvlut)(Handle mem, void* lut, float eps, float tau, float t_env,
float t_path) = nullptr;
};
// Load the SDK. RTLD_GLOBAL, because the camera plugins it loads in dll_init()
// resolve their symbols against it; without it no device type registers. The
// library stays loaded for the life of the process. nullptr, with `error` set,
// if the library or one of its entry points is missing.
std::shared_ptr<const Api> load(const std::string& library, std::string& error);
// "0x80000017 (NUC in progress or invalid licence)", for logs.
std::string describe(Ret r);
} // namespace fgc::irbgrab

View File

@ -17,10 +17,12 @@ namespace fgc {
// BayerRG12p the same mosaic at 12 bits, packed: two pixels in three bytes // BayerRG12p the same mosaic at 12 bits, packed: two pixels in three bytes
// (GenICam "12p": p0 = b0 | (b1 & 0x0F) << 8, // (GenICam "12p": p0 = b0 | (b1 & 0x0F) << 8,
// p1 = b1 >> 4 | b2 << 4) // p1 = b1 >> 4 | b2 << 4)
// Mono16 two bytes per pixel, grey, host byte order - the thermal
// camera's counts (TarisCameraSource.h), all 16 bits used
// //
// OpenCV names Bayer patterns by the second row, so the camera's BayerRG is // 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). // OpenCV's COLOR_BayerBG2* (measured 2026-09-30 against the camera's own RGB8).
enum class PixelLayout { Unknown, Mono8, RGB8, BayerRG8, BayerRG12p }; enum class PixelLayout { Unknown, Mono8, RGB8, BayerRG8, BayerRG12p, Mono16 };
// The GenICam name ("BayerRG12p"), or "Unknown". // The GenICam name ("BayerRG12p"), or "Unknown".
const char* layoutName(PixelLayout layout); const char* layoutName(PixelLayout layout);
@ -29,7 +31,7 @@ PixelLayout layoutFromName(const std::string& name);
// True for the raw mosaic layouts, which need de-Bayering before saving as colour. // True for the raw mosaic layouts, which need de-Bayering before saving as colour.
bool layoutIsBayer(PixelLayout layout); bool layoutIsBayer(PixelLayout layout);
// Bits per value as delivered: 8, or 12 for BayerRG12p. 0 for Unknown. // Bits per value as delivered: 8, 12 for BayerRG12p, 16 for Mono16. 0 for Unknown.
int layoutBits(PixelLayout layout); int layoutBits(PixelLayout layout);
// Bytes one frame of width x height takes in this layout (0 for Unknown). // 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); size_t layoutFrameBytes(PixelLayout layout, uint32_t width, uint32_t height);
@ -42,7 +44,8 @@ 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 // 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 // pixel, so all colour sites of a mosaic are visited. Cheap enough to run on
// every frame the camera delivers (a few thousand samples). // every frame the camera delivers (a few thousand samples).
// mean on a 0..255 scale whatever the bit depth (12-bit values / 16) // mean on a 0..255 scale whatever the bit depth (12-bit values / 16,
// 16-bit values / 256)
// clipped share of samples at or near white (>= 250 on that scale) // clipped share of samples at or near white (>= 250 on that scale)
struct BrightnessSample { struct BrightnessSample {
double mean = -1.0; double mean = -1.0;

View File

@ -0,0 +1,81 @@
#pragma once
#include "fgc/Config.h"
#include "fgc/ICameraSource.h"
#include "fgc/IrbgrabApi.h"
#include <functional>
#include <memory>
#include <string>
namespace fgc {
// The thermal camera: an InfraTec TarisIR mini through the IRBGRAB SDK
// (IrbgrabApi.h), one camera per source.
//
// The camera is kept grabbing from open() to close(), whatever the scan does:
// a microbolometer that is stopped and restarted starts with zero frames and a
// fresh NUC. A worker thread owns every SDK call - connecting, polling the
// newest frame of counts, switching the calibration profile, reconnecting
// when the camera goes quiet - so nothing here ever blocks the control loop.
// trigger() hands over the newest frame if it is fresh enough, and otherwise
// says no at once: the thermal picture of a waypoint is best effort, the
// colour camera's is not.
//
// A camera that is missing, unlicensed or unreachable is not a startup error.
// The source reports why in offline(), keeps retrying, and the scan goes on
// without it.
class TarisCameraSource : public ICameraSource {
public:
struct Options {
std::string serial; // the camera's serial number
int calib_profile = 0;
double frame_rate_hz = 5.0;
int max_frame_age_ms = 400;
std::string library = "libirbgrab_x86_64-linux.so";
std::string interface; // for diagnostics only; may be empty
int stall_ms = 5000; // connected but no frame this long: reconnect
int retry_min_ms = 2000; // between connection attempts, doubling...
int retry_max_ms = 30000;// ...up to this
int calib_timeout_ms = 15000;// a profile switch takes about 3 s
};
static Options fromSpec(const CameraSpec& spec);
// api: the SDK, or null to load opts.library when the worker starts.
// has_net_raw: may this process open a raw socket (the SDK needs one)?
// Null: read the effective capabilities from /proc/self/status.
explicit TarisCameraSource(Options opts, std::shared_ptr<const irbgrab::Api> api = nullptr,
std::function<bool()> has_net_raw = nullptr);
~TarisCameraSource() override;
void open() override;
void close() override;
// Only open and close delivery; the camera keeps grabbing either way.
void start() override;
void stop() override;
bool trigger() override;
void setFrameCallback(FrameCallback cb) override;
int cameraCount() const override { return 1; }
std::vector<CameraDeviceInfo> deviceInfo() override;
std::string offline(int camera_index) override;
bool grabFrame(int camera_index, Frame& out, int timeout_ms) override;
// "CalibProfile" (index), "KelvinPerCount", "HousingTemperature" (degC).
bool readFeature(int camera_index, const std::string& name, double& value) override;
// "CalibProfile": switch, and wait until the camera has (seconds).
bool setFeature(int camera_index, const std::string& name, const std::string& value) override;
// Disconnect and connect again; waits until it is back or gives up.
bool resetCamera(int camera_index) override;
// The counts' scale the profiles are documented to have (TarisIR mini):
// 0.01 K for profile 0, 0.02 K for profile 1; 0 for any other.
static double expectedKelvinPerCount(int calib_profile);
// Does /proc/self/status grant CAP_NET_RAW?
static bool processHasNetRaw();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace fgc

View File

@ -0,0 +1,45 @@
#pragma once
#include <cstddef>
#include <cstdint>
namespace fgc {
// What a thermal camera reports about one frame besides its pixels. The pixels
// themselves are counts (PixelLayout::Mono16): already calibrated apparent
// temperature, T[K] = count * kelvin_per_count, with emissivity and path
// transmission both taken as 1. The scale depends on the camera's active
// calibration profile, so a count means nothing without `cal`.
struct ThermalFrameInfo {
int cal = 0; // active calibration profile index
double kelvin_per_count = 0.01; // K per count under that profile
uint16_t valid_count_max = 65280; // above this the camera's LUT wraps
int missed_packets = 0; // GigE packets the SDK lost for this frame
long long image_num = -1; // the camera's own frame counter
};
// The per-frame figures that go into a thermal CamEvent. Temperatures are the
// apparent temperature in tenths of a degree Celsius; the hot spot is the
// pixel centre, normalised 0..1 across the image as stored.
struct ThermalStats {
bool valid = false; // false: no pixel in the valid range
size_t valid_pixels = 0;
uint16_t max_count = 0;
uint16_t median_count = 0;
int tmax_decic = 0;
int tmed_decic = 0;
double hotx = 0.0;
double hoty = 0.0;
};
// Tenths of a degree Celsius for a count, rounded to nearest.
int countsToDeciC(uint32_t count, double kelvin_per_count);
// Maximum (its first occurrence, row-major), median and hot spot of a
// width x height image of counts. Only counts in 1..info.valid_count_max take
// part: 0 is what the camera sends before its first real frame, and anything
// above the limit has wrapped.
ThermalStats thermalStats(const uint16_t* counts, uint32_t width, uint32_t height,
const ThermalFrameInfo& info);
} // namespace fgc

View File

@ -5,6 +5,7 @@
#include <algorithm> #include <algorithm>
#include <chrono> #include <chrono>
#include <cstring>
#include <map> #include <map>
#include <string> #include <string>
#include <thread> #include <thread>
@ -13,8 +14,10 @@ namespace fgc {
// Synthetic camera for development without hardware. On trigger() it generates // Synthetic camera for development without hardware. On trigger() it generates
// a small gradient frame (varying with time so successive captures differ) and // 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 // delivers it to the frame callback - as RGB8 by default, as the raw mosaic
// (BayerRG8, BayerRG12p) a raw-capture profile would deliver. setProfile() only // (BayerRG8, BayerRG12p) a raw-capture profile would deliver, or as a thermal
// camera's counts (Mono16: a 15..25 degC gradient with one 300 degC spot that
// moves with time, profile 0). setProfile() only
// records the profile name - there is no sensor to configure - which is enough // records the profile name - there is no sensor to configure - which is enough
// to exercise profile switching end to end with --mock-camera. // to exercise profile switching end to end with --mock-camera.
class MockCameraSource : public ICameraSource { class MockCameraSource : public ICameraSource {
@ -54,6 +57,11 @@ public:
} }
bool readFeature(int camera_index, const std::string& name, double& value) override { bool readFeature(int camera_index, const std::string& name, double& value) override {
if (camera_index < 0 || camera_index >= count_) return false; if (camera_index < 0 || camera_index >= count_) return false;
if (layout_ == PixelLayout::Mono16) { // a thermal camera, like TarisCameraSource
if (name == "CalibProfile") { value = 0.0; return true; }
if (name == "KelvinPerCount") { value = 0.01; return true; }
return false;
}
if (name == "IntensityControllerTarget") { value = 50.0; return true; } if (name == "IntensityControllerTarget") { value = 50.0; return true; }
if (name == "IntensityControllerTolerance") { value = 5.0; return true; } if (name == "IntensityControllerTolerance") { value = 5.0; return true; }
if (name == "ExposureAutoMin") { value = 82.0; return true; } if (name == "ExposureAutoMin") { value = 82.0; return true; }
@ -95,6 +103,7 @@ public:
bool featureRange(int camera_index, const std::string& name, double& min, bool featureRange(int camera_index, const std::string& name, double& min,
double& max) override { double& max) override {
if (camera_index < 0 || camera_index >= count_) return false; if (camera_index < 0 || camera_index >= count_) return false;
if (layout_ == PixelLayout::Mono16) return false; // a thermal camera: no exposure
if (name == "ExposureTime") { min = 66.357; max = 9999971.0; return true; } if (name == "ExposureTime") { min = 66.357; max = 9999971.0; return true; }
if (name == "Gain") { min = 0.0; max = 48.0; return true; } if (name == "Gain") { min = 0.0; max = 48.0; return true; }
if (name == "BlackLevel") { min = 0.0; max = 4095.0; return true; } if (name == "BlackLevel") { min = 0.0; max = 4095.0; return true; }
@ -137,6 +146,7 @@ private:
// A mid-grey gradient frame. The pattern varies with time so successive // A mid-grey gradient frame. The pattern varies with time so successive
// captures differ. // captures differ.
Frame synth() const { Frame synth() const {
if (layout_ == PixelLayout::Mono16) return thermal();
Frame f; Frame f;
f.width = width_; f.width = width_;
f.height = height_; f.height = height_;
@ -182,6 +192,31 @@ private:
return f; return f;
} }
// A thermal scene in counts at 0.01 K per count.
Frame thermal() const {
Frame f;
f.width = width_;
f.height = height_;
f.channels = 1;
f.layout = PixelLayout::Mono16;
f.cam_id = 0;
f.timestamp_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
std::vector<uint16_t> px(static_cast<size_t>(width_) * height_);
for (uint32_t y = 0; y < height_; ++y)
for (uint32_t x = 0; x < width_; ++x) // 288.15 K at the top .. 298.15 K
px[static_cast<size_t>(y) * width_ + x] =
static_cast<uint16_t>(28815 + 1000 * y / std::max<uint32_t>(1, height_ - 1));
const uint32_t hx = static_cast<uint32_t>(f.timestamp_ms / 100) % width_;
px[static_cast<size_t>(height_ / 2) * width_ + hx] = 57315; // 300 degC
f.data.resize(px.size() * 2);
std::memcpy(f.data.data(), px.data(), f.data.size());
f.brightness = sampleBrightness(f.data.data(), f.data.size(), f.layout, f.width, f.height);
f.thermal = ThermalFrameInfo{};
return f;
}
// The RGB frame as the sensor would see it: each pixel keeps only the colour // 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. // of its filter (BayerRG: R G / G B), 8-bit or packed 12-bit.
Frame mosaic(const Frame& rgb) const { Frame mosaic(const Frame& rgb) const {

View File

@ -33,10 +33,9 @@ public:
drop(kTopicStatusCode, payload); drop(kTopicStatusCode, payload);
} }
void publishCamEvent(const CamEvent& e) override { void publishCamEvent(const CamEvent& e) override {
LOG_TRACE_CAT(LogCat::Mqtt) << "PUB CamEvent cam=" << e.camera const std::string payload = camEventJson(e);
<< " hdg=" << e.heading_decideg << " t=" << e.timestamp_ms LOG_TRACE_CAT(LogCat::Mqtt) << "PUB CamEvent " << payload << " (null channel)";
<< " (null channel)"; drop(kTopicCamEvent, payload);
drop(kTopicCamEvent, camEventJson(e));
} }
void publishHealth(const HealthReport& h) override { void publishHealth(const HealthReport& h) override {
++health_published_; ++health_published_;

View File

@ -144,6 +144,7 @@ struct CameraDeviceView {
unsigned width = 0, height = 0; long long payload_bytes = 0; unsigned width = 0, height = 0; long long payload_bytes = 0;
double exposure_us = 0, gain_db = 0, temperature_c = 0, actual_fps = 0; double exposure_us = 0, gain_db = 0, temperature_c = 0, actual_fps = 0;
long long frames_delivered = 0, frames_dropped = 0, restarts = 0; long long frames_delivered = 0, frames_dropped = 0, restarts = 0;
std::string offline; // why it delivers nothing; empty while it does
}; };
struct CaptureView { struct CaptureView {
@ -172,6 +173,15 @@ struct CaptureView {
double last_heading_deg = 0.0; double last_heading_deg = 0.0;
double last_pitch_deg = 0.0; double last_pitch_deg = 0.0;
long long last_ts_ms = 0; long long last_ts_ms = 0;
// Last thermal picture (ImagePipeline::lastThermalEvent()): its apparent
// temperatures, so a hot spot shows on the dashboard without the archive.
bool has_thermal = false;
int thermal_cal = 0;
bool thermal_stats_ok = false;
double thermal_max_c = 0.0;
double thermal_med_c = 0.0;
long long thermal_ts_ms = 0;
}; };
struct ConnView { struct ConnView {

82
scripts/fgc-setcap Executable file
View File

@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Give fire_gimbal_control the one privilege the thermal camera needs.
#
# The InfraTec SDK talks to the TarisIR over a raw AF_PACKET socket, which
# takes CAP_NET_RAW. Rather than running the whole program - motors, MQTT
# remote control and all - as root, the binary gets that one capability and
# keeps running as the tower's user. A rebuild replaces the binary and with it
# the capability, so deploy.sh and install-tower run this after every build.
#
# scripts/fgc-setcap set it (asks for the sudo password)
# scripts/fgc-setcap --if-thermal ... only if a [Camera.*] has driver = taris;
# never fails, says what is missing
# scripts/fgc-setcap --check exit 0 if the binary has it
# scripts/fgc-setcap --install-sudoers allow exactly this setcap without a
# password, so deploy.sh can run it
#
# A binary with file capabilities runs in the loader's secure mode: LD_* is
# ignored and no core dumps are written. So the SDK (/usr/lib/infratec, in the
# ld cache) and Vimba X must be found without LD_LIBRARY_PATH - see
# docs/tower-installation.md, "Thermal camera", for the check.
set -euo pipefail
REPO=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
BIN="$REPO/build/fire_gimbal_control"
CAPS="cap_net_raw+ep"
SETCAP=/usr/sbin/setcap # fixed: the sudoers rule names this exact path
[[ -x $SETCAP ]] || SETCAP=$(command -v setcap 2>/dev/null || echo /usr/sbin/setcap)
SUDOERS=/etc/sudoers.d/fire-gimbal-control-setcap
HW=/etc/fire_gimbal_control/hardware.ini
thermal_configured() {
local f cfg
cfg=$("$REPO/scripts/tower-config" where 2>/dev/null || true)
for f in "$cfg" "$REPO/config.ini" "$HW"; do
[[ -r $f ]] && grep -Eqi '^[[:space:]]*driver[[:space:]]*=[[:space:]]*taris' "$f" && return 0
done
return 1
}
has_cap() { command -v getcap > /dev/null && getcap "$BIN" 2>/dev/null | grep -q cap_net_raw; }
set_cap() {
[[ -x $BIN ]] || { echo "fgc-setcap: $BIN not built yet" >&2; return 1; }
if sudo -n "$SETCAP" "$CAPS" "$BIN" 2>/dev/null; then :
elif [[ -t 0 ]]; then sudo "$SETCAP" "$CAPS" "$BIN"
else return 1
fi
has_cap
}
case "${1:-}" in
--check)
has_cap
;;
--install-sudoers)
rule="$(id -un) ALL=(root) NOPASSWD: $SETCAP $CAPS $BIN"
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
printf '# fire_gimbal_control: the thermal camera needs cap_net_raw after every build\n%s\n' \
"$rule" > "$tmp"
/usr/sbin/visudo -cf "$tmp" > /dev/null || { echo "fgc-setcap: rule rejected by visudo" >&2; exit 1; }
sudo install -m 0440 -o root -g root "$tmp" "$SUDOERS"
echo "wrote $SUDOERS: $rule"
;;
--if-thermal)
thermal_configured || exit 0
if set_cap; then
echo "fgc-setcap: $BIN may open raw sockets (thermal camera)"
else
echo "!! fgc-setcap: could not give $BIN $CAPS - the thermal camera stays offline." >&2
echo "!! Once on the tower: scripts/fgc-setcap --install-sudoers (or run scripts/fgc-setcap)" >&2
fi
exit 0
;;
"")
set_cap && echo "fgc-setcap: $BIN may open raw sockets"
;;
*)
sed -n '2,20p' "$0" >&2
exit 2
;;
esac

View File

@ -1064,6 +1064,15 @@ if [[ ${DO[config.ini]} != skip ]]; then
done_step config.ini done_step config.ini
fi fi
# A thermal camera's SDK opens a raw socket: give the freshly built binary
# cap_net_raw, and let later deploys do the same without a password. Does
# nothing on a tower without one.
"$REPO/scripts/fgc-setcap" --if-thermal
if "$REPO/scripts/fgc-setcap" --check 2>/dev/null && [[ ! -f /etc/sudoers.d/fire-gimbal-control-setcap ]]; then
"$REPO/scripts/fgc-setcap" --install-sudoers ||
say "could not write the sudoers rule; deploy.sh will ask you to run scripts/fgc-setcap"
fi
if [[ ${DO[platformio]} == change ]]; then if [[ ${DO[platformio]} == change ]]; then
hr platformio hr platformio
curl -fsSL https://raw.githubusercontent.com/platformio/platformio-core-installer/master/get-platformio.py -o "$WORK/get-platformio.py" curl -fsSL https://raw.githubusercontent.com/platformio/platformio-core-installer/master/get-platformio.py -o "$WORK/get-platformio.py"

View File

@ -0,0 +1,165 @@
#include "fgc/CompositeCameraSource.h"
#include "fgc/Logger.h"
#include <stdexcept>
namespace fgc {
CompositeCameraSource::CompositeCameraSource(std::vector<std::unique_ptr<ICameraSource>> children,
std::vector<int> counts)
: children_(std::move(children)), counts_(std::move(counts)) {
if (children_.empty()) throw std::invalid_argument("CompositeCameraSource needs a camera");
if (counts_.empty())
for (const auto& c : children_) counts_.push_back(c->cameraCount());
if (counts_.size() != children_.size())
throw std::invalid_argument("CompositeCameraSource: one camera count per source");
int next = 0;
for (int n : counts_) {
offsets_.push_back(next);
next += n;
}
}
ICameraSource* CompositeCameraSource::route(int camera_index, int& local) const {
for (size_t k = children_.size(); k-- > 0;) {
if (camera_index < offsets_[k]) continue;
local = camera_index - offsets_[k];
return local < counts_[k] ? children_[k].get() : nullptr;
}
return nullptr;
}
void CompositeCameraSource::open() {
// The leader failing stops the run, as a lone camera would. A follower
// failing does not: the scan goes on without its pictures.
children_[0]->open();
for (size_t k = 1; k < children_.size(); ++k) {
try {
children_[k]->open();
} catch (const std::exception& e) {
LOG_ERROR << "camera " << offsets_[k] << " did not open (" << e.what()
<< "); continuing without it";
}
}
}
void CompositeCameraSource::close() {
for (auto& c : children_) c->close();
}
void CompositeCameraSource::start() {
for (auto& c : children_) c->start();
}
void CompositeCameraSource::stop() {
for (auto& c : children_) c->stop();
}
bool CompositeCameraSource::trigger() {
in_trigger_ = true;
shot_ts_ = 0;
const bool taken = children_[0]->trigger();
if (taken)
for (size_t k = 1; k < children_.size(); ++k) children_[k]->trigger();
in_trigger_ = false;
return taken;
}
void CompositeCameraSource::setFrameCallback(FrameCallback cb) {
callback_ = std::move(cb);
// The leader's frames pass through untouched (they can be 24 MB); its
// camera 0 is global camera 0, so not even the index changes.
children_[0]->setFrameCallback([this](const Frame& f) {
if (in_trigger_ && shot_ts_ == 0) shot_ts_ = f.timestamp_ms;
if (callback_) callback_(f);
});
for (size_t k = 1; k < children_.size(); ++k) {
const int offset = offsets_[k];
children_[k]->setFrameCallback([this, offset](const Frame& f) {
if (!callback_) return;
Frame g = f;
g.cam_id = f.cam_id + offset;
if (in_trigger_ && shot_ts_ != 0) g.timestamp_ms = shot_ts_;
callback_(g);
});
}
}
bool CompositeCameraSource::setProfile(int camera_index, const CameraProfile& profile) {
int local = 0;
auto* c = route(camera_index, local);
return c && c->setProfile(local, profile);
}
uint64_t CompositeCameraSource::frameSeq() { return children_[0]->frameSeq(); }
std::vector<FrameStats> CompositeCameraSource::framesSince(uint64_t after) {
return children_[0]->framesSince(after);
}
bool CompositeCameraSource::readFeature(int camera_index, const std::string& name,
double& value) {
int local = 0;
auto* c = route(camera_index, local);
return c && c->readFeature(local, name, value);
}
// What the children have opened, so a camera missing from the bus shows as a
// shortfall (HardwarePresence) - while the indices stay those configured.
int CompositeCameraSource::cameraCount() const {
int n = 0;
for (const auto& c : children_) n += c->cameraCount();
return n;
}
std::vector<CameraDeviceInfo> CompositeCameraSource::deviceInfo() {
std::vector<CameraDeviceInfo> out;
for (auto& c : children_) {
auto d = c->deviceInfo();
out.insert(out.end(), d.begin(), d.end());
}
return out;
}
std::string CompositeCameraSource::offline(int camera_index) {
int local = 0;
auto* c = route(camera_index, local);
return c ? c->offline(local) : "no such camera";
}
bool CompositeCameraSource::grabFrame(int camera_index, Frame& out, int timeout_ms) {
int local = 0;
auto* c = route(camera_index, local);
if (!c || !c->grabFrame(local, out, timeout_ms)) return false;
out.cam_id = camera_index;
return true;
}
bool CompositeCameraSource::setFeature(int camera_index, const std::string& name,
const std::string& value) {
int local = 0;
auto* c = route(camera_index, local);
return c && c->setFeature(local, name, value);
}
bool CompositeCameraSource::featureRange(int camera_index, const std::string& name, double& min,
double& max) {
int local = 0;
auto* c = route(camera_index, local);
return c && c->featureRange(local, name, min, max);
}
bool CompositeCameraSource::restoreProfile(int camera_index) {
int local = 0;
auto* c = route(camera_index, local);
return c && c->restoreProfile(local);
}
bool CompositeCameraSource::resetCamera(int camera_index) {
int local = 0;
auto* c = route(camera_index, local);
return c && c->resetCamera(local);
}
} // namespace fgc

View File

@ -174,7 +174,8 @@ void ImagePipeline::process(const QueuedFrame& queued) {
} }
const fs::path file = dir / (std::to_string(frame.timestamp_ms) + ".jxl"); const fs::path file = dir / (std::to_string(frame.timestamp_ms) + ".jxl");
const auto t0 = std::chrono::steady_clock::now(); const auto t0 = std::chrono::steady_clock::now();
if (!save(frame, out, file.string())) return; ThermalStats stats;
if (!save(frame, out, file.string(), &stats)) return;
noteSaveTime(std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0) noteSaveTime(std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0)
.count()); .count());
@ -187,10 +188,25 @@ void ImagePipeline::process(const QueuedFrame& queued) {
ev.heading_decideg = static_cast<int>(o.yaw_deg * 10); ev.heading_decideg = static_cast<int>(o.yaw_deg * 10);
ev.pitch_decideg = static_cast<int>(o.pitch_deg * 10); ev.pitch_decideg = static_cast<int>(o.pitch_deg * 10);
ev.timestamp_ms = frame.timestamp_ms; ev.timestamp_ms = frame.timestamp_ms;
if (frame.thermal) {
ThermalEventFields t;
t.cal = frame.thermal->cal;
t.missed = frame.thermal->missed_packets;
t.stats_ok = stats.valid;
t.tmax_decic = stats.tmax_decic;
t.tmed_decic = stats.tmed_decic;
t.hotx = stats.hotx;
t.hoty = stats.hoty;
ev.thermal = t;
if (!stats.valid)
LOG_WARN << "Thermal frame " << frame.timestamp_ms
<< " has no pixel in the valid range; saved, but without temperatures";
}
channel_.publishCamEvent(ev); channel_.publishCamEvent(ev);
{ {
std::lock_guard<std::mutex> lock(last_event_mutex_); std::lock_guard<std::mutex> lock(last_event_mutex_);
last_event_ = ev; last_event_ = ev;
if (ev.thermal) last_thermal_event_ = ev;
} }
saved_.fetch_add(1); saved_.fetch_add(1);
@ -198,15 +214,24 @@ void ImagePipeline::process(const QueuedFrame& queued) {
<< (known ? layoutName(frame.layout) : "legacy") << ')'; << (known ? layoutName(frame.layout) : "legacy") << ')';
} }
bool ImagePipeline::save(const Frame& frame, const CameraOutput& out, const std::string& file) { bool ImagePipeline::save(const Frame& frame, const CameraOutput& out, const std::string& file,
ThermalStats* stats) {
int code = 0; int code = 0;
bool rotate = rotateCode(out.rotation_deg, code); bool rotate = rotateCode(out.rotation_deg, code);
const int h = static_cast<int>(frame.height), w = static_cast<int>(frame.width); const int h = static_cast<int>(frame.height), w = static_cast<int>(frame.width);
auto* data = const_cast<uint8_t*>(frame.data.data()); 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 cv::Mat img; // what gets encoded: 8-bit (CV_8U), or 12- or 16-bit values in CV_16U
int bits = 8; int bits = 8;
if (frame.layout == PixelLayout::BayerRG8 || frame.layout == PixelLayout::BayerRG12p) { float distance = static_cast<float>(out.jxl_distance);
if (frame.layout == PixelLayout::Mono16) {
// Thermal counts: every bit is measurement, so always lossless whatever
// the camera's jxl_distance says (the config loader refuses anything
// else for a thermal camera; this is the backstop).
img = cv::Mat(h, w, CV_16UC1, data);
bits = 16;
distance = 0.f;
} else if (frame.layout == PixelLayout::BayerRG8 || frame.layout == PixelLayout::BayerRG12p) {
cv::Mat mosaic; cv::Mat mosaic;
if (frame.layout == PixelLayout::BayerRG8) { if (frame.layout == PixelLayout::BayerRG8) {
mosaic = cv::Mat(h, w, CV_8UC1, data); mosaic = cv::Mat(h, w, CV_8UC1, data);
@ -231,9 +256,14 @@ bool ImagePipeline::save(const Frame& frame, const CameraOutput& out, const std:
} }
if (!img.isContinuous()) img = img.clone(); if (!img.isContinuous()) img = img.clone();
// On the image as stored, so the hot spot's position matches the file.
if (stats && frame.thermal && frame.layout == PixelLayout::Mono16)
*stats = thermalStats(reinterpret_cast<const uint16_t*>(img.data),
static_cast<uint32_t>(img.cols), static_cast<uint32_t>(img.rows),
*frame.thermal);
if (!JpegXlEncoder::encodeToFile(file, img.data, img.cols, img.rows, img.channels(), bits, if (!JpegXlEncoder::encodeToFile(file, img.data, img.cols, img.rows, img.channels(), bits,
static_cast<float>(out.jxl_distance), out.jxl_effort, distance, out.jxl_effort, params_.jxl_threads)) {
params_.jxl_threads)) {
LOG_ERROR << "Failed to encode " << file; LOG_ERROR << "Failed to encode " << file;
return false; return false;
} }
@ -245,4 +275,9 @@ std::optional<CamEvent> ImagePipeline::lastEvent() const {
return last_event_; return last_event_;
} }
std::optional<CamEvent> ImagePipeline::lastThermalEvent() const {
std::lock_guard<std::mutex> lock(last_event_mutex_);
return last_thermal_event_;
}
} // namespace fgc } // namespace fgc

61
src/camera/IrbgrabApi.cpp Normal file
View File

@ -0,0 +1,61 @@
#include "fgc/IrbgrabApi.h"
#include <cstdio>
#include <type_traits>
#include <dlfcn.h>
namespace fgc::irbgrab {
std::shared_ptr<const Api> load(const std::string& library, std::string& error) {
// Never dlclose()d: the SDK starts threads and loads plugins of its own.
void* lib = dlopen(library.c_str(), RTLD_NOW | RTLD_GLOBAL);
if (!lib) {
const char* e = dlerror();
error = "cannot load " + library + ": " + (e ? e : "unknown error");
return nullptr;
}
auto api = std::make_shared<Api>();
bool missing = false;
auto sym = [&](auto& fn, const char* name) {
fn = reinterpret_cast<std::remove_reference_t<decltype(fn)>>(dlsym(lib, name));
if (!fn && !missing) {
missing = true;
error = library + " has no " + name + " (not the IRBGRAB v4 SDK?)";
}
};
sym(api->dll_init, "irbgrab_dll_init");
sym(api->dll_uninit, "irbgrab_dll_uninit");
sym(api->dll_version, "irbgrab_dll_version");
sym(api->dll_devicetypenames, "irbgrab_dll_devicetypenames");
sym(api->dev_create, "irbgrab_dev_create");
sym(api->dev_free, "irbgrab_dev_free");
sym(api->dev_search, "irbgrab_dev_search");
sym(api->dev_connect, "irbgrab_dev_connect");
sym(api->dev_disconnect, "irbgrab_dev_disconnect");
sym(api->dev_startgrab, "irbgrab_dev_startgrab");
sym(api->dev_stopgrab, "irbgrab_dev_stopgrab");
sym(api->dev_getparam, "irbgrab_dev_getparam");
sym(api->dev_setparam, "irbgrab_dev_setparam");
sym(api->dev_getdata, "irbgrab_dev_getdata");
sym(api->mem_free, "irbgrab_mem_free");
sym(api->mem_getdimension, "irbgrab_mem_getdimension");
sym(api->mem_getdataptr, "irbgrab_mem_getdataptr");
sym(api->mem_getinfo, "irbgrab_mem_getinfo");
sym(api->mem_getdvlut, "irbgrab_mem_getdvlut");
if (missing) return nullptr;
return api;
}
std::string describe(Ret r) {
char b[16];
std::snprintf(b, sizeof(b), "0x%08X", static_cast<unsigned>(r));
std::string s = b;
if (r == kSuccess) s += " (ok)";
else if (r == kErrNotSupported) s += " (not supported)";
else if (r == kErrNotFound) s += " (not found)";
else if (r == kErrNucOrLicence) s += " (NUC in progress or invalid licence)";
return s;
}
} // namespace fgc::irbgrab

View File

@ -0,0 +1,815 @@
#include "fgc/TarisCameraSource.h"
#include "fgc/Logger.h"
#include "fgc/RawImage.h"
#include <algorithm>
#include <atomic>
#include <cctype>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cstring>
#include <deque>
#include <clocale>
#include <fstream>
#include <mutex>
#include <sstream>
#include <thread>
#include <vector>
namespace fgc {
using namespace irbgrab;
namespace {
long long unixMs() {
using namespace std::chrono;
return duration_cast<milliseconds>(system_clock::now().time_since_epoch()).count();
}
long long steadyMs() {
using namespace std::chrono;
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
}
std::string lower(std::string s) {
for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
// One line of a sysfs file, or "" if it cannot be read.
std::string sysfs(const std::string& path) {
std::ifstream f(path);
std::string s;
std::getline(f, s);
return s;
}
// The SDK changes the process's C locale (seen on tower 111: LC_NUMERIC turned
// to a comma one under LANG=en_US.UTF-8). The program parses and prints numbers
// with the C library, so every SDK call that may do this runs inside one of
// these, which puts the locale back as it was.
class LocaleGuard {
public:
LocaleGuard() {
const char* now = std::setlocale(LC_ALL, nullptr);
saved_ = now ? now : "C";
}
~LocaleGuard() {
const char* now = std::setlocale(LC_ALL, nullptr);
if (!now || saved_ != now) std::setlocale(LC_ALL, saved_.c_str());
}
LocaleGuard(const LocaleGuard&) = delete;
LocaleGuard& operator=(const LocaleGuard&) = delete;
private:
std::string saved_;
};
constexpr long long kLogEveryMs = 60000; // repeated trouble: one line a minute
constexpr long long kTelemetryEveryMs = 5000;
} // namespace
TarisCameraSource::Options TarisCameraSource::fromSpec(const CameraSpec& spec) {
Options o;
o.serial = spec.id;
o.calib_profile = spec.calib_profile;
o.frame_rate_hz = spec.frame_rate_hz;
o.max_frame_age_ms = spec.max_frame_age_ms;
o.library = spec.library;
o.interface = spec.interface;
return o;
}
double TarisCameraSource::expectedKelvinPerCount(int calib_profile) {
switch (calib_profile) {
case 0: return 0.01; // FF (-40-120C)
case 1: return 0.02; // FF (0-600C)
default: return 0.0;
}
}
bool TarisCameraSource::processHasNetRaw() {
std::ifstream f("/proc/self/status");
std::string line;
while (std::getline(f, line)) {
if (line.rfind("CapEff:", 0) != 0) continue;
try {
const unsigned long long caps = std::stoull(line.substr(7), nullptr, 16);
return (caps >> 13) & 1; // CAP_NET_RAW
} catch (const std::exception&) {
return false;
}
}
return false;
}
struct TarisCameraSource::Impl {
Options o;
std::shared_ptr<const Api> api;
std::function<bool()> has_net_raw;
// ---- the worker's own (only it calls the SDK) ---------------------------
Handle dev = 0;
bool sdk_init = false;
bool connected = false;
int cal = -1; // active profile as the camera reports it
double kpc = 0.0; // measured from the LUT; 0 = not yet
long long last_frame_steady = 0;
double last_sdk_ts = -1.0; // MemInfo time of the last frame taken
int scale_tries = 0; // frames whose LUT was still empty
long long last_telemetry = 0;
long long nuc_or_licence = 0; // getdata 0x80000017 since the last log line
long long nuc_logged_ms = 0;
bool zeros_logged = false;
std::string last_offline_logged;
// ---- shared, under m -------------------------------------------------
std::mutex m;
std::condition_variable cv;
Frame latest;
uint64_t latest_seq = 0;
uint64_t delivered_seq = 0;
std::string why_offline = "not opened";
CameraDeviceInfo info;
std::deque<long long> arrivals; // steady ms of recent frames, for fps
long long missed_shots = 0;
long long missed_logged_ms = 0;
long long missed_since_log = 0;
long long packets_lost = 0;
int cal_wanted = -1; // a pending setFeature("CalibProfile")
bool cal_done = false;
bool cal_ok = false;
bool reset_wanted = false;
uint64_t connects = 0;
std::atomic<bool> running{false};
std::atomic<bool> gate{false};
FrameCallback callback;
std::thread worker;
explicit Impl(Options opts, std::shared_ptr<const Api> a, std::function<bool()> net_raw)
: o(std::move(opts)), api(std::move(a)), has_net_raw(std::move(net_raw)) {
if (!has_net_raw) has_net_raw = &TarisCameraSource::processHasNetRaw;
info.id = o.serial;
info.serial = o.serial;
info.model = "TarisIR";
}
void setOffline(const std::string& why) {
{
std::lock_guard<std::mutex> l(m);
why_offline = why;
info.streaming = false;
}
if (why != last_offline_logged) {
LOG_WARN << "thermal camera " << o.serial << " offline: " << why;
last_offline_logged = why;
}
}
// Sleep up to ms, or less if close() wants the worker to stop or another
// thread has asked for a profile switch or a reset.
void pause(long long ms) {
std::unique_lock<std::mutex> l(m);
cv.wait_for(l, std::chrono::milliseconds(ms),
[this] { return !running.load() || cal_wanted >= 0 || reset_wanted; });
}
// ---- parameter helpers -------------------------------------------------
bool getInt32(uint32_t what, int32_t& v) {
return ok(api->dev_getparam(dev, what, &v, kTypeInt32));
}
bool getIdxInt32(uint32_t what, int32_t index, int32_t& v) {
IdxInt32 p{index, 0};
if (!ok(api->dev_getparam(dev, what, &p, kTypeIdxInt32))) return false;
v = p.value;
return true;
}
bool getSingle(uint32_t what, float& v) {
return ok(api->dev_getparam(dev, what, &v, kTypeSingle));
}
bool getString(uint32_t what, std::string& out) {
std::vector<char> buf(kCharBuffer, 0);
String s{buf.data(), kCharBuffer};
if (!ok(api->dev_getparam(dev, what, &s, kTypeString))) return false;
out.assign(buf.data(), strnlen(buf.data(), buf.size()));
return true;
}
bool getIdxString(uint32_t what, int32_t index, std::string& out) {
std::vector<char> buf(kCharBuffer, 0);
IdxString s{index, {buf.data(), kCharBuffer}};
if (!ok(api->dev_getparam(dev, what, &s, kTypeIdxString))) return false;
out.assign(buf.data(), strnlen(buf.data(), buf.size()));
return true;
}
Ret setInt32(uint32_t what, int32_t v) { return api->dev_setparam(dev, what, &v, kTypeInt32); }
// Calib_Active reads as a plain or an indexed value (both work on 4.3.44).
// Right after a reconnect it can fail for a moment, so it is asked a few times.
int readCal() {
for (int attempt = 0; attempt < 10; ++attempt) {
int32_t v = -1;
if (getInt32(param::kCalibActive, v) && v >= 0) return v;
if (getIdxInt32(param::kCalibActive, 0, v) && v >= 0) return v;
std::this_thread::sleep_for(std::chrono::milliseconds(300));
}
return -1;
}
// Switch the calibration profile and wait for the camera to finish.
// Calib_Active reports the new profile at once, but the frames keep the old
// scale until Calib_ChangeInProgress has run 2, 4, 2 and reached 0 (about
// 3 s on tower 111; at rest, before any switch, it reads 6). Nothing is
// polled meanwhile, so no frame of the wrong scale is taken.
bool switchCal(int profile, std::string& why) {
int32_t count = 0;
if (getInt32(param::kCalibCount, count) && (profile < 0 || profile >= count)) {
why = "calibration profile " + std::to_string(profile) + " does not exist (camera has " +
std::to_string(count) + ")";
return false;
}
IdxInt32 v{0, profile};
const Ret r = api->dev_setparam(dev, param::kCalibActive, &v, kTypeIdxInt32);
if (!ok(r)) {
why = "cannot set calibration profile " + std::to_string(profile) + ": " + describe(r);
return false;
}
const long long until = steadyMs() + o.calib_timeout_ms;
int32_t busy = 1;
while (steadyMs() < until) {
if (getInt32(param::kCalibChangeInProgress, busy) && busy == 0) break;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
if (busy != 0) {
why = "calibration profile change did not finish within " +
std::to_string(o.calib_timeout_ms) + " ms";
return false;
}
cal = readCal();
kpc = 0.0; // measure the new scale from the next frames' LUT
scale_tries = 0;
if (cal != profile) {
why = "camera reports profile " + std::to_string(cal) + " after switching to " +
std::to_string(profile);
return false;
}
std::string name;
getIdxString(param::kCalibName, cal, name);
LOG_INFO << "thermal camera " << o.serial << ": calibration profile " << cal
<< (name.empty() ? "" : " (" + name + ")");
return true;
}
// ---- connecting --------------------------------------------------------
std::string linkHint() const {
if (o.interface.empty()) return {};
const std::string base = "/sys/class/net/" + o.interface;
const std::string state = sysfs(base + "/operstate");
if (state.empty()) return "; interface " + o.interface + " does not exist";
std::string hint = "; " + o.interface + " is " + state;
const std::string mtu = sysfs(base + "/mtu");
if (!mtu.empty() && mtu != "1500") hint += ", MTU " + mtu + " (the camera needs 1500)";
return hint;
}
void freeDevice() {
if (dev) api->dev_free(&dev);
dev = 0;
}
void disconnect() {
if (connected) {
api->dev_stopgrab(dev, 0);
api->dev_disconnect(dev);
}
connected = false;
freeDevice();
}
bool connect(std::string& why) {
// The device type the camera package registered.
std::vector<char> names(kCharBuffer, 0);
Ret r = api->dll_devicetypenames(names.data(), kCharBuffer);
if (!ok(r)) {
why = "SDK lists no device types: " + describe(r);
return false;
}
int type = -1, i = 0;
std::stringstream ss(std::string(names.data(), strnlen(names.data(), names.size())));
for (std::string n; std::getline(ss, n, ';'); ++i)
if (lower(n).find("taris") != std::string::npos) { type = i; break; }
if (type < 0) {
why = "SDK has no TarisIR device type (camera package infratec-itcamtarisirmini "
"missing?)";
return false;
}
std::vector<char> ini(kCharBuffer, 0);
r = api->dev_create(&dev, type, ini.data());
if (!ok(r)) {
dev = 0;
why = "cannot create the device: " + describe(r);
return false;
}
// Search first: it fills the SDK's adapter table that connect() needs.
int32_t count = 0;
r = api->dev_search(dev, &count);
if (!ok(r) || count <= 0) {
freeDevice();
why = "no thermal camera found on the network" + linkHint();
return false;
}
std::string chosen;
std::vector<std::string> seen;
for (int32_t k = 0; k < count && k < 32; ++k) {
std::string s;
if (!getIdxString(param::kSearchString, k, s)) break;
seen.push_back(s);
if (chosen.empty() && s.find(o.serial) != std::string::npos) chosen = s;
}
if (chosen.empty()) {
std::string list;
for (const auto& s : seen) list += (list.empty() ? "" : ", ") + s;
freeDevice();
why = "camera " + o.serial + " not found (found: " + (list.empty() ? "none" : list) + ")";
return false;
}
// SN:<serial>|<MAC>|<IP>#<adapter>. The adapter index moves when Docker or
// VPN interfaces come and go, so if the one found does not connect, try
// them all.
std::vector<std::string> candidates{chosen};
const std::string base = chosen.substr(0, chosen.rfind('#'));
for (int a = 0; a < 16; ++a) {
std::string c = base + "#" + std::to_string(a);
if (c != chosen) candidates.push_back(c);
}
Ret last = 0;
for (const auto& c : candidates) {
std::vector<char> buf(kCharBuffer, 0);
std::memcpy(buf.data(), c.data(), std::min<size_t>(c.size(), kCharBuffer - 1));
last = api->dev_connect(dev, buf.data());
if (ok(last)) {
connected = true;
LOG_INFO << "thermal camera connected via " << c;
break;
}
}
if (!connected) {
freeDevice();
why = "cannot connect to " + chosen + ": " + describe(last) + linkHint();
return false;
}
// Profile: it sets the counts' scale, so it is checked before any frame.
cal = readCal();
if (cal < 0) {
disconnect();
why = "cannot read the active calibration profile";
return false;
}
if (cal != o.calib_profile) {
LOG_INFO << "thermal camera on calibration profile " << cal << ", switching to "
<< o.calib_profile;
std::string err;
if (!switchCal(o.calib_profile, err)) {
disconnect();
why = err;
return false;
}
}
float rate = static_cast<float>(o.frame_rate_hz);
r = api->dev_setparam(dev, param::kFramerateHz, &rate, kTypeSingle);
if (!ok(r))
LOG_WARN << "thermal camera: cannot set " << o.frame_rate_hz
<< " Hz: " << describe(r) << "; it runs at its own rate";
// Polling, before and again after starting: the SDK resets it on startgrab.
setInt32(param::kPollingEnable, 1);
r = api->dev_startgrab(dev, 0);
if (!ok(r)) {
disconnect();
why = "cannot start grabbing: " + describe(r);
return false;
}
setInt32(param::kPollingEnable, 1);
std::string model;
getString(param::kCameraName, model);
{
std::lock_guard<std::mutex> l(m);
if (!model.empty()) info.model = model;
++connects;
}
kpc = 0.0;
scale_tries = 0;
last_sdk_ts = -1.0;
zeros_logged = false;
last_frame_steady = steadyMs(); // grace for the first frame
return true;
}
// ---- frames ------------------------------------------------------------
// Take the newest frame from the SDK, if there is one.
void poll() {
// As before every getdata in the vendor's examples: without it the SDK
// now and then answers 0x80000001 instead of a frame.
setInt32(param::kPollingEnable, 1);
Handle mem = 0;
const Ret r = api->dev_getdata(dev, kMemIrDigitFrame, &mem);
if (!ok(r)) {
if (r == kErrNucOrLicence) {
++nuc_or_licence;
const long long now = steadyMs();
if (now - nuc_logged_ms >= kLogEveryMs) {
LOG_WARN << "thermal camera: " << describe(r) << " x" << nuc_or_licence
<< " in the last minute; if this persists, check the licence in "
"/var/lib/infratec/.license before the NUC";
nuc_logged_ms = now;
nuc_or_licence = 0;
}
}
return;
}
struct MemGuard {
const Api* api;
Handle* h;
~MemGuard() { if (*h) api->mem_free(h); }
} guard{api.get(), &mem};
uint32_t w = 0, h = 0, type = 0;
Handle ptr = 0;
int32_t bytes = 0;
if (!ok(api->mem_getdimension(mem, &w, &h, &type)) ||
!ok(api->mem_getdataptr(mem, &ptr, &bytes)) || !ptr || w == 0 || h == 0)
return;
const size_t need = static_cast<size_t>(w) * h * 2;
if (bytes < 0 || static_cast<size_t>(bytes) < need) return;
// Polling hands out the newest frame again until a newer one arrives. The
// camera's frame counter stays 0 (TarisIR mini, SDK 4.3.44), so a new
// frame is told by its time.
MemInfo mi{};
mi.struct_size = sizeof(MemInfo);
if (!ok(api->mem_getinfo(mem, &mi))) {
mi.image_num = -1;
mi.timestamp_ms = -1.0;
}
if (mi.timestamp_ms >= 0.0 && mi.timestamp_ms == last_sdk_ts) return; // seen it
last_sdk_ts = mi.timestamp_ms;
const auto* counts = reinterpret_cast<const uint16_t*>(ptr);
// The first frames after starting are all zero.
if (std::all_of(counts, counts + static_cast<size_t>(w) * h,
[](uint16_t c) { return c == 0; })) {
if (!zeros_logged) LOG_INFO << "thermal camera: discarding zero frames after start";
zeros_logged = true;
return;
}
if (kpc == 0.0) measureScale(mem);
Frame f;
f.width = w;
f.height = h;
f.channels = 1;
f.layout = PixelLayout::Mono16;
f.timestamp_ms = unixMs();
f.cam_id = 0;
f.data.assign(reinterpret_cast<const uint8_t*>(ptr),
reinterpret_cast<const uint8_t*>(ptr) + need);
f.brightness = sampleBrightness(f.data.data(), f.data.size(), f.layout, w, h);
ThermalFrameInfo t;
t.cal = cal;
t.kelvin_per_count = kpc > 0.0 ? kpc : expectedKelvinPerCount(cal);
t.missed_packets = mi.missed_packets;
t.image_num = mi.image_num;
f.thermal = t;
const long long now = steadyMs();
last_frame_steady = now;
{
std::lock_guard<std::mutex> l(m);
latest = std::move(f);
++latest_seq;
++info.frames_delivered;
packets_lost += std::max(0, mi.missed_packets);
info.frames_dropped = missed_shots + packets_lost;
info.width = w;
info.height = h;
info.payload_bytes = static_cast<long long>(need);
info.streaming = true;
why_offline.clear();
arrivals.push_back(now);
while (!arrivals.empty() && now - arrivals.front() > 10000) arrivals.pop_front();
if (arrivals.size() > 1)
info.actual_fps = (arrivals.size() - 1) * 1000.0 /
std::max<long long>(1, arrivals.back() - arrivals.front());
}
cv.notify_all();
last_offline_logged.clear();
}
// The counts' scale, from the LUT at emissivity and transmission 1, where it
// is a straight line through zero. The first frames after connecting come
// with an empty table, so it is asked again on later ones; frames meanwhile
// use the documented scale, and if the table stays empty, so does the run.
static constexpr int kScaleTries = 50;
void measureScale(Handle mem) {
std::vector<float> lut(65536, 0.f);
const Ret r = api->mem_getdvlut(mem, lut.data(), 1.f, 1.f, 293.15f, 293.15f);
const double expected = expectedKelvinPerCount(cal);
double measured = 0.0;
if (ok(r)) measured = (static_cast<double>(lut[40000]) - lut[30000]) / 10000.0;
if (!(measured > 0.0) && ++scale_tries < kScaleTries) return; // try the next frame
LOG_DEBUG << "thermal camera LUT after " << scale_tries << " empty: " << describe(r)
<< ", [0] " << lut[0] << " [29315] " << lut[29315] << " [65280] "
<< lut[65280] << " [65535] " << lut[65535] << " K";
if (!(measured > 0.0)) {
kpc = expected > 0.0 ? expected : -1.0; // -1: unknown, and not asked again
LOG_WARN << "thermal camera: no usable LUT (" << describe(r) << "); taking the "
<< "documented " << expected << " K per count for profile " << cal;
} else {
kpc = measured;
if (expected > 0.0 && std::abs(kpc - expected) > expected * 0.01)
LOG_WARN << "thermal camera: profile " << cal << " has " << kpc
<< " K per count, not the documented " << expected;
else
LOG_INFO << "thermal camera: profile " << cal << ", " << kpc << " K per count";
}
}
void telemetry() {
float k = 0.f;
if (getSingle(param::kCameraTempHousing, k) && k > 0.f) {
std::lock_guard<std::mutex> l(m);
info.temperature_c = k - 273.15;
}
}
// ---- the worker ------------------------------------------------------
void run() {
if (!api) {
std::string err;
LocaleGuard keep;
api = irbgrab::load(o.library, err);
if (!api) {
setOffline(err + " (InfraTec SDK not installed?)");
return;
}
}
if (!has_net_raw()) {
setOffline("this binary may not open raw sockets (no cap_net_raw): run deploy.sh, "
"which sets it, or sudo setcap cap_net_raw+ep build/fire_gimbal_control");
return;
}
Ret r;
{
LocaleGuard keep;
r = api->dll_init();
}
if (!ok(r)) {
setOffline("SDK init failed: " + describe(r));
return;
}
sdk_init = true;
std::vector<char> ver(kCharBuffer, 0);
if (ok(api->dll_version(ver.data(), kCharBuffer)))
LOG_INFO << "IRBGRAB SDK " << ver.data();
long long retry = o.retry_min_ms;
const long long poll_ms =
std::max<long long>(5, static_cast<long long>(500.0 / o.frame_rate_hz));
while (running) {
LocaleGuard keep_locale; // every SDK call in this pass, polling included
if (!connected) {
{
// Connecting is what a reset asks for; it must not keep the
// retry pause from pausing.
std::lock_guard<std::mutex> l(m);
reset_wanted = false;
}
std::string why;
bool up;
{
LocaleGuard keep;
up = connect(why);
}
if (up) {
retry = o.retry_min_ms;
std::lock_guard<std::mutex> l(m);
why_offline = "waiting for the first frame";
} else {
setOffline(why);
pause(retry);
retry = std::min<long long>(retry * 2, o.retry_max_ms);
continue;
}
}
serviceRequests();
if (!connected) continue;
poll();
const long long now = steadyMs();
if (now - last_frame_steady > o.stall_ms) {
setOffline("no frame for " + std::to_string(o.stall_ms) + " ms; reconnecting");
disconnect();
std::lock_guard<std::mutex> l(m);
++info.restarts;
continue;
}
if (now - last_telemetry >= kTelemetryEveryMs) {
telemetry();
last_telemetry = now;
}
pause(poll_ms);
}
LocaleGuard keep;
disconnect();
if (sdk_init) api->dll_uninit();
sdk_init = false;
}
// A profile switch or a reset asked for from another thread.
void serviceRequests() {
int want = -1;
bool reset = false;
{
std::lock_guard<std::mutex> l(m);
want = cal_wanted;
reset = reset_wanted;
reset_wanted = false;
}
if (reset) {
LOG_INFO << "thermal camera: reset requested, reconnecting";
disconnect();
std::lock_guard<std::mutex> l(m);
++info.restarts;
return;
}
if (want < 0) return;
std::string why;
bool done = switchCal(want, why);
if (done) o.calib_profile = want; // a reconnect keeps it
else LOG_WARN << "thermal camera: " << why;
{
std::lock_guard<std::mutex> l(m);
cal_wanted = -1;
cal_done = true;
cal_ok = done;
// Frames from before the switch are on the old scale: never hand one out.
delivered_seq = latest_seq;
}
cv.notify_all();
}
};
TarisCameraSource::TarisCameraSource(Options opts, std::shared_ptr<const irbgrab::Api> api,
std::function<bool()> has_net_raw)
: impl_(std::make_unique<Impl>(std::move(opts), std::move(api), std::move(has_net_raw))) {}
TarisCameraSource::~TarisCameraSource() { close(); }
void TarisCameraSource::open() {
if (impl_->running.exchange(true)) return;
{
std::lock_guard<std::mutex> l(impl_->m);
impl_->why_offline = "connecting";
}
impl_->worker = std::thread([this] { impl_->run(); });
LOG_INFO << "thermal camera " << impl_->o.serial << ": connecting in the background";
}
void TarisCameraSource::close() {
if (!impl_->running.exchange(false)) return;
impl_->cv.notify_all();
if (impl_->worker.joinable()) impl_->worker.join();
std::lock_guard<std::mutex> l(impl_->m);
impl_->why_offline = "closed";
impl_->info.streaming = false;
}
void TarisCameraSource::start() { impl_->gate = true; }
void TarisCameraSource::stop() { impl_->gate = false; }
void TarisCameraSource::setFrameCallback(FrameCallback cb) { impl_->callback = std::move(cb); }
bool TarisCameraSource::trigger() {
Impl& d = *impl_;
if (!d.gate || !d.callback) return false;
Frame f;
{
std::lock_guard<std::mutex> l(d.m);
const bool fresh = d.latest_seq > d.delivered_seq &&
unixMs() - d.latest.timestamp_ms <= d.o.max_frame_age_ms;
if (!fresh) {
++d.missed_shots;
++d.missed_since_log;
d.info.frames_dropped = d.missed_shots + d.packets_lost;
const long long now = steadyMs();
if (now - d.missed_logged_ms >= kLogEveryMs) {
LOG_WARN << "thermal camera: no fresh frame for " << d.missed_since_log
<< " picture(s) in the last minute"
<< (d.why_offline.empty() ? "" : " (" + d.why_offline + ")");
d.missed_logged_ms = now;
d.missed_since_log = 0;
}
return false;
}
f = d.latest;
d.delivered_seq = d.latest_seq;
}
d.callback(f);
return true;
}
std::vector<CameraDeviceInfo> TarisCameraSource::deviceInfo() {
std::lock_guard<std::mutex> l(impl_->m);
return {impl_->info};
}
std::string TarisCameraSource::offline(int camera_index) {
if (camera_index != 0) return "no such camera";
std::lock_guard<std::mutex> l(impl_->m);
return impl_->why_offline;
}
bool TarisCameraSource::grabFrame(int camera_index, Frame& out, int timeout_ms) {
if (camera_index != 0) return false;
Impl& d = *impl_;
std::unique_lock<std::mutex> l(d.m);
const uint64_t after = d.latest_seq;
if (!d.cv.wait_for(l, std::chrono::milliseconds(timeout_ms),
[&] { return d.latest_seq > after || !d.running; }) ||
d.latest_seq <= after)
return false;
out = d.latest;
return true;
}
bool TarisCameraSource::readFeature(int camera_index, const std::string& name, double& value) {
if (camera_index != 0) return false;
std::lock_guard<std::mutex> l(impl_->m);
if (name == "CalibProfile") {
if (!impl_->latest.thermal) return false;
value = impl_->latest.thermal->cal;
return true;
}
if (name == "KelvinPerCount") {
if (!impl_->latest.thermal) return false;
value = impl_->latest.thermal->kelvin_per_count;
return true;
}
if (name == "HousingTemperature") {
if (impl_->info.temperature_c == 0.0) return false;
value = impl_->info.temperature_c;
return true;
}
return false;
}
bool TarisCameraSource::setFeature(int camera_index, const std::string& name,
const std::string& value) {
if (camera_index != 0 || name != "CalibProfile") return false;
int profile = -1;
try {
size_t used = 0;
profile = std::stoi(value, &used);
if (used != value.size()) return false;
} catch (const std::exception&) {
return false;
}
Impl& d = *impl_;
std::unique_lock<std::mutex> l(d.m);
if (!d.running || !d.why_offline.empty()) return false;
d.cal_wanted = profile;
d.cal_done = false;
d.cv.notify_all();
if (!d.cv.wait_for(l, std::chrono::milliseconds(d.o.calib_timeout_ms + 5000),
[&] { return d.cal_done || !d.running; }))
return false;
return d.cal_done && d.cal_ok;
}
bool TarisCameraSource::resetCamera(int camera_index) {
if (camera_index != 0) return false;
Impl& d = *impl_;
std::unique_lock<std::mutex> l(d.m);
if (!d.running) return false;
const uint64_t before = d.connects;
const uint64_t seq = d.latest_seq;
d.reset_wanted = true;
d.cv.notify_all();
// Back once it has connected again and delivered a frame.
return d.cv.wait_for(l, std::chrono::seconds(45), [&] {
return (d.connects > before && d.latest_seq > seq) || !d.running;
}) && d.running;
}
} // namespace fgc

View File

@ -63,6 +63,9 @@
#include <string> #include <string>
#include <thread> #include <thread>
#include "fgc/CompositeCameraSource.h"
#include "fgc/TarisCameraSource.h"
#if FGC_WITH_MQTT #if FGC_WITH_MQTT
#include "fgc/MqttControlChannel.h" #include "fgc/MqttControlChannel.h"
#endif #endif
@ -420,6 +423,9 @@ struct Application::Impl {
#endif #endif
} }
// One source for the colour cameras (Vimba, or the mock), one per thermal
// camera, behind a CompositeCameraSource when there is more than one. The
// colour source leads (CameraConfig::cameras puts its cameras first).
std::unique_ptr<ICameraSource> makeCamera() { std::unique_ptr<ICameraSource> makeCamera() {
bool mock = !realCameraWanted(); bool mock = !realCameraWanted();
if (mock && !cfg.features.enable_camera) { if (mock && !cfg.features.enable_camera) {
@ -431,7 +437,27 @@ struct Application::Impl {
LOG_WARN << "Camera requested but binary built without Vimba X; using mock camera"; LOG_WARN << "Camera requested but binary built without Vimba X; using mock camera";
} }
#endif #endif
const auto active = cfg.camera.active(); std::vector<const CameraSpec*> colour, thermal;
for (const auto* c : cfg.camera.active()) (c->thermal() ? thermal : colour).push_back(c);
// With the camera count each source is configured with: Vimba reports
// none until open(), and the thermal camera's index must not move.
std::vector<std::unique_ptr<ICameraSource>> sources;
std::vector<int> counts;
if (!colour.empty() || thermal.empty()) {
sources.push_back(makeColourCamera(colour, mock));
counts.push_back(colour.empty() ? 1 : static_cast<int>(colour.size()));
}
for (const auto* c : thermal) {
sources.push_back(makeThermalCamera(*c));
counts.push_back(1);
}
if (sources.size() == 1) return std::move(sources.front());
return std::make_unique<CompositeCameraSource>(std::move(sources), std::move(counts));
}
std::unique_ptr<ICameraSource> makeColourCamera(const std::vector<const CameraSpec*>& active,
bool mock) {
if (mock) { if (mock) {
const int n = active.empty() ? 1 : static_cast<int>(active.size()); const int n = active.empty() ? 1 : static_cast<int>(active.size());
if (opts.mock_camera_full.value_or(cfg.features.mock_camera_full)) { if (opts.mock_camera_full.value_or(cfg.features.mock_camera_full)) {
@ -453,6 +479,19 @@ struct Application::Impl {
#endif #endif
} }
// A thermal camera needs no build option: its SDK is loaded at run time, and
// a tower without it just has that camera offline. Only a mock or disabled
// camera run gets synthetic counts instead.
std::unique_ptr<ICameraSource> makeThermalCamera(const CameraSpec& c) {
if (!cfg.features.enable_camera || opts.mock_camera.value_or(cfg.features.mock_camera)) {
LOG_INFO << "[mock] thermal camera '" << c.name << "': synthetic 640x480 counts";
return std::make_unique<MockCameraSource>(1, 640, 480, PixelLayout::Mono16);
}
LOG_INFO << "thermal camera '" << c.name << "': TarisIR " << c.id << ", profile "
<< c.calib_profile << ", " << c.frame_rate_hz << " Hz";
return std::make_unique<TarisCameraSource>(TarisCameraSource::fromSpec(c));
}
// Load every profile of every camera up front. Two reasons: a profile nobody // Load every profile of every camera up front. Two reasons: a profile nobody
// is using today still fails at startup rather than the first time someone // is using today still fails at startup rather than the first time someone
// switches to it, and a settings XML edited in the Viewer is detected and // switches to it, and a settings XML edited in the Viewer is detected and
@ -464,6 +503,14 @@ struct Application::Impl {
for (const auto& c : cfg.camera.cameras) { for (const auto& c : cfg.camera.cameras) {
if (!c.enabled) continue; if (!c.enabled) continue;
if (c.thermal()) {
// No settings file: the thermal camera is set up from its config
// keys, and its profile only names a timing block.
active_profile[c.name] = c.profile;
LOG_INFO << "camera '" << c.name << "': thermal, no settings XML; timing '"
<< c.profile << "'";
continue;
}
std::map<std::string, fgc::ProfileLoadResult> results; std::map<std::string, fgc::ProfileLoadResult> results;
auto profiles = fgc::loadCameraProfiles(c.profile_dir, results); auto profiles = fgc::loadCameraProfiles(c.profile_dir, results);
@ -500,6 +547,26 @@ struct Application::Impl {
// Switch one camera's profile by name. With no camera named, applies to the // Switch one camera's profile by name. With no camera named, applies to the
// single configured camera. Returns false (and warns) if unknown. // single configured camera. Returns false (and warns) if unknown.
bool applyProfile(const std::string& cam_name, const std::string& name) { bool applyProfile(const std::string& cam_name, const std::string& name) {
for (const auto& c : cfg.camera.cameras) {
if (c.name != cam_name || !c.thermal()) continue;
// A thermal camera's profile is its timing block and nothing else.
if (!c.enabled) {
LOG_WARN << "camera '" << cam_name << "' is configured but not enabled";
return false;
}
if (name != c.profile && c.timing.find(name) == c.timing.end()) {
std::string known = c.profile;
for (const auto& [n, t] : c.timing)
if (n != c.profile) known += ", " + n;
LOG_WARN << "unknown profile '" << name << "' for camera '" << cam_name
<< "' (available: " << known << ')';
return false;
}
active_profile[cam_name] = name;
configureSettle();
LOG_INFO << "camera '" << cam_name << "' profile -> " << name << " (timing only)";
return true;
}
auto ci = camera_profiles.find(cam_name); auto ci = camera_profiles.find(cam_name);
if (ci == camera_profiles.end()) { if (ci == camera_profiles.end()) {
std::string known; std::string known;
@ -551,6 +618,15 @@ struct Application::Impl {
rule = rule || t.settle_rule == "brightness"; rule = rule || t.settle_rule == "brightness";
} }
scheduler->setSettleWait(settle, timeout); scheduler->setSettleWait(settle, timeout);
// The thermal camera hands over the newest frame it has, up to
// max_frame_age_ms old: older than the settle wait, and it may have been
// taken while the gimbal still moved.
for (const auto* c : cfg.camera.active())
if (c->thermal() && c->max_frame_age_ms > settle)
LOG_WARN << "camera '" << c->name << "': a frame may be up to "
<< c->max_frame_age_ms << " ms old, but the scan waits only " << settle
<< " ms after standstill - lower max_frame_age_ms, or the thermal "
"image may be blurred by the last move";
SettleRule::Params p; SettleRule::Params p;
if (rule && camera) { if (rule && camera) {
double v = 0; double v = 0;
@ -1037,8 +1113,10 @@ struct Application::Impl {
// Live per-camera identity/telemetry/stats. // Live per-camera identity/telemetry/stats.
s.capture.devices.clear(); s.capture.devices.clear();
if (camera) { if (camera) {
int index = 0;
for (const auto& d : camera->deviceInfo()) { for (const auto& d : camera->deviceInfo()) {
CameraDeviceView v; CameraDeviceView v;
v.offline = camera->offline(index++);
v.id = d.id; v.model = d.model; v.serial = d.serial; v.streaming = d.streaming; v.id = d.id; v.model = d.model; v.serial = d.serial; v.streaming = d.streaming;
v.width = d.width; v.height = d.height; v.payload_bytes = d.payload_bytes; v.width = d.width; v.height = d.height; v.payload_bytes = d.payload_bytes;
v.exposure_us = d.exposure_us; v.gain_db = d.gain_db; v.exposure_us = d.exposure_us; v.gain_db = d.gain_db;
@ -1067,6 +1145,14 @@ struct Application::Impl {
s.capture.last_pitch_deg = ev->pitch_decideg / 10.0; s.capture.last_pitch_deg = ev->pitch_decideg / 10.0;
s.capture.last_ts_ms = ev->timestamp_ms; s.capture.last_ts_ms = ev->timestamp_ms;
} }
if (auto ev = pipeline->lastThermalEvent(); ev && ev->thermal) {
s.capture.has_thermal = true;
s.capture.thermal_cal = ev->thermal->cal;
s.capture.thermal_stats_ok = ev->thermal->stats_ok;
s.capture.thermal_max_c = ev->thermal->tmax_decic / 10.0;
s.capture.thermal_med_c = ev->thermal->tmed_decic / 10.0;
s.capture.thermal_ts_ms = ev->timestamp_ms;
}
} }
// --- Connectivity --- // --- Connectivity ---
@ -1337,6 +1423,14 @@ struct Application::Impl {
h.save_ms_mean = t.mean_ms; h.save_ms_mean = t.mean_ms;
h.save_ms_max = t.max_ms; h.save_ms_max = t.max_ms;
} }
if (camera) {
// Only a camera the scan can do without is ever offline (the
// thermal one); the colour camera failing stops the run instead.
const auto active = cfg.camera.active();
for (int i = 0; i < camera->cameraCount() && i < static_cast<int>(active.size()); ++i)
if (!camera->offline(i).empty())
h.cams_offline += (h.cams_offline.empty() ? "" : ",") + active[i]->label;
}
h.held_s = thermal_.heldForSaveMs() / 1000; h.held_s = thermal_.heldForSaveMs() / 1000;
h.interval_s = scheduler ? scheduler->intervalMs() / 1000.0 : 0.0; h.interval_s = scheduler ? scheduler->intervalMs() / 1000.0 : 0.0;
h.timestamp_ms = nowEpochMs(); h.timestamp_ms = nowEpochMs();
@ -2077,12 +2171,77 @@ struct Application::Impl {
// profile. For a stream that has stopped delivering and will not restart - // profile. For a stream that has stopped delivering and will not restart -
// the state tower 111's camera was in on 2026-09-24, which only this cleared. // the state tower 111's camera was in on 2026-09-24, which only this cleared.
// Manual mode only (allowedIn), so there is no scan to pause around it. // Manual mode only (allowedIn), so there is no scan to pause around it.
void resetCameras() { // With a camera named (block name or label), only that one: the thermal
// camera reconnects without rebooting the colour one.
void resetCameras(const std::string& which = "") {
const auto active = cfg.camera.active();
int only = -1;
auto lowered = [](std::string v) {
std::transform(v.begin(), v.end(), v.begin(),
[](unsigned char ch) { return std::tolower(ch); });
return v;
};
if (!which.empty()) {
for (size_t i = 0; i < active.size(); ++i)
if (lowered(active[i]->name) == lowered(which) ||
lowered(active[i]->label) == lowered(which))
only = static_cast<int>(i);
if (only < 0) {
LOG_WARN << "camera reset: no active camera '" << which << "'";
return;
}
}
camera->stop(); camera->stop();
for (int i = 0; i < camera->cameraCount(); ++i) for (int i = 0; i < camera->cameraCount(); ++i) {
LOG_INFO << "camera " << i << (camera->resetCamera(i) ? ": reset, back on its profile" if (only >= 0 && i != only) continue;
const std::string name = i < static_cast<int>(active.size())
? active[i]->name
: std::to_string(i);
LOG_INFO << "camera " << name
<< (camera->resetCamera(i) ? ": reset, back on its profile"
: ": reset FAILED (see above)"); : ": reset FAILED (see above)");
} }
}
// `camera calib [<profile>]`: the thermal camera's calibration profile,
// which sets the scale of its counts. Every frame says which it was taken
// on, so switching is safe for the archive; it is manual-mode only because
// the camera delivers nothing for the ~3 s it takes.
void handleCalib(const Command& c) {
int index = -1, i = 0;
for (const auto* s : cfg.camera.active()) {
if (s->thermal()) { index = i; break; }
++i;
}
if (index < 0) {
LOG_WARN << "camera calib: no thermal camera configured";
return;
}
const std::string why = camera->offline(index);
if (!why.empty()) {
LOG_WARN << "camera calib: the thermal camera is offline (" << why << ")";
return;
}
if (!c.has_value) {
double v = 0, k = 0;
if (camera->readFeature(index, "CalibProfile", v) &&
camera->readFeature(index, "KelvinPerCount", k))
LOG_INFO << "thermal calibration profile " << static_cast<int>(v) << ", " << k
<< " K per count";
else
LOG_INFO << "thermal calibration profile: no frame yet";
return;
}
const int profile = static_cast<int>(c.value);
LOG_INFO << "thermal camera: switching to calibration profile " << profile << " ...";
if (camera->setFeature(index, "CalibProfile", std::to_string(profile)))
LOG_INFO << "thermal camera on calibration profile " << profile
<< " (for this run; [Camera." << cfg.camera.active()[index]->name
<< "] calib_profile sets it at startup)";
else
LOG_WARN << "thermal camera: switching to calibration profile " << profile
<< " FAILED (see above)";
}
// The one way the mode changes - `mode ...`, the a / p / m keys, [Startup] mode, and a // The one way the mode changes - `mode ...`, the a / p / m keys, [Startup] mode, and a
// test pausing the scan all come here. Returns false, having said why and // test pausing the scan all come here. Returns false, having said why and
@ -3160,8 +3319,9 @@ struct Application::Impl {
} else if (c.verb == "exit") { } else if (c.verb == "exit") {
running = false; running = false;
} else if (c.verb == "camera") { } else if (c.verb == "camera") {
if (c.device == "reset") resetCameras(); if (c.device == "reset") resetCameras(c.option);
else LOG_WARN << "usage: camera reset"; else if (c.device == "calib") handleCalib(c);
else LOG_WARN << "usage: camera reset [<camera>] | camera calib [<profile>]";
} else if (c.verb == "snapshot") { } else if (c.verb == "snapshot") {
takeSnapshot(); takeSnapshot();
} else if (c.verb == "capture") { } else if (c.verb == "capture") {

View File

@ -1,6 +1,7 @@
#include "fgc/ChannelTopics.h" #include "fgc/ChannelTopics.h"
#include <cctype> #include <cctype>
#include <cmath>
#include <cstdio> #include <cstdio>
namespace fgc { namespace fgc {
@ -9,23 +10,47 @@ std::string towerTopic(const std::string& tower, const std::string& leaf) {
return "GGS/FWT/" + tower + "/" + leaf; return "GGS/FWT/" + tower + "/" + leaf;
} }
std::string camEventJson(const CamEvent& e) {
return "{ \"fwt\":\"" + e.tower + "\", \"cam\":\"" + e.camera +
"\", \"hdg\":" + std::to_string(e.heading_decideg) +
", \"pit\":" + std::to_string(e.pitch_decideg) +
", \"time\":" + std::to_string(e.timestamp_ms) + " }";
}
namespace { namespace {
// Fixed-point, with a '.' whatever the C locale says. printf would follow
// LC_NUMERIC, and the thermal camera's SDK sets that to a comma locale when it
// loads (seen on tower 111: "hotx":0,4336) - which is no longer JSON.
std::string num(double v, int decimals) { std::string num(double v, int decimals) {
char b[32]; if (!std::isfinite(v)) return "null";
std::snprintf(b, sizeof(b), "%.*f", decimals, v); long long scale = 1;
return b; for (int i = 0; i < decimals; ++i) scale *= 10;
const long long n = std::llround(v * static_cast<double>(scale));
const bool neg = n < 0;
const unsigned long long a = neg ? 0ULL - static_cast<unsigned long long>(n)
: static_cast<unsigned long long>(n);
std::string out = (neg ? "-" : "") + std::to_string(a / static_cast<unsigned long long>(scale));
if (decimals > 0) {
std::string frac = std::to_string(a % static_cast<unsigned long long>(scale));
out += "." + std::string(static_cast<size_t>(decimals) - frac.size(), '0') + frac;
}
return out;
} }
} // namespace } // namespace
std::string camEventJson(const CamEvent& e) {
std::string out = "{ \"fwt\":" + jsonString(e.tower) + ", \"cam\":" + jsonString(e.camera) +
", \"hdg\":" + std::to_string(e.heading_decideg) +
", \"pit\":" + std::to_string(e.pitch_decideg) +
", \"time\":" + std::to_string(e.timestamp_ms);
if (e.thermal) {
const ThermalEventFields& t = *e.thermal;
auto opt = [&t](const std::string& v) { return t.stats_ok ? v : std::string("null"); };
out += ", \"cal\":" + std::to_string(t.cal) +
", \"missed\":" + std::to_string(t.missed) +
", \"tmax\":" + opt(std::to_string(t.tmax_decic)) +
", \"tmed\":" + opt(std::to_string(t.tmed_decic)) +
", \"hotx\":" + opt(num(t.hotx, 4)) +
", \"hoty\":" + opt(num(t.hoty, 4));
}
return out + " }";
}
std::string healthJson(const HealthReport& h) { std::string healthJson(const HealthReport& h) {
auto opt = [](bool ok, double v, int d) { return ok ? num(v, d) : std::string("null"); }; auto opt = [](bool ok, double v, int d) { return ok ? num(v, d) : std::string("null"); };
return "{\"fwt\":" + jsonString(h.tower) + ",\"state\":" + jsonString(h.state) + return "{\"fwt\":" + jsonString(h.tower) + ",\"state\":" + jsonString(h.state) +
@ -40,6 +65,7 @@ std::string healthJson(const HealthReport& h) {
",\"saved_last_min\":" + std::to_string(h.save_count_last_min) + ",\"saved_last_min\":" + std::to_string(h.save_count_last_min) +
",\"save_ms_mean\":" + opt(h.save_count_last_min > 0, h.save_ms_mean, 0) + ",\"save_ms_mean\":" + opt(h.save_count_last_min > 0, h.save_ms_mean, 0) +
",\"save_ms_max\":" + opt(h.save_count_last_min > 0, h.save_ms_max, 0) + ",\"save_ms_max\":" + opt(h.save_count_last_min > 0, h.save_ms_max, 0) +
",\"cams_offline\":" + jsonString(h.cams_offline) +
",\"held_s\":" + std::to_string(h.held_s) + ",\"held_s\":" + std::to_string(h.held_s) +
",\"interval_s\":" + num(h.interval_s, 1) + ",\"interval_s\":" + num(h.interval_s, 1) +
",\"time\":" + std::to_string(h.timestamp_ms) + "}"; ",\"time\":" + std::to_string(h.timestamp_ms) + "}";

View File

@ -383,9 +383,17 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
c.profile_dir = get(kv, pre + "profile_dir"); c.profile_dir = get(kv, pre + "profile_dir");
c.profile = get(kv, pre + "profile", c.profile); c.profile = get(kv, pre + "profile", c.profile);
c.rotation_deg = getInt(kv, pre + "rotation_deg", c.rotation_deg); c.rotation_deg = getInt(kv, pre + "rotation_deg", c.rotation_deg);
c.jxl_distance = getDouble(kv, pre + "jxl_distance", cfg.camera.jxl_distance); // A thermal camera's counts are measurements: lossless unless someone
// asks otherwise, and asking is refused below.
c.jxl_distance = getDouble(kv, pre + "jxl_distance",
c.driver == "taris" ? 0.0 : cfg.camera.jxl_distance);
c.jxl_effort = getInt(kv, pre + "jxl_effort", cfg.camera.jxl_effort); c.jxl_effort = getInt(kv, pre + "jxl_effort", cfg.camera.jxl_effort);
c.demosaic = lowered(get(kv, pre + "demosaic", cfg.camera.demosaic)); c.demosaic = lowered(get(kv, pre + "demosaic", cfg.camera.demosaic));
c.calib_profile = getInt(kv, pre + "calib_profile", c.calib_profile);
c.frame_rate_hz = getDouble(kv, pre + "frame_rate_hz", c.frame_rate_hz);
c.max_frame_age_ms = getInt(kv, pre + "max_frame_age_ms", c.max_frame_age_ms);
c.library = get(kv, pre + "library", c.library);
c.interface = get(kv, pre + "interface");
// Per-profile timing: Camera.<name>.<profile>.settle_wait_ms // Per-profile timing: Camera.<name>.<profile>.settle_wait_ms
for (const auto& [key, val] : kv) { for (const auto& [key, val] : kv) {
@ -407,13 +415,28 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
"'fixed' or 'brightness'"); "'fixed' or 'brightness'");
} }
if (c.driver != "vimba") if (c.driver != "vimba" && c.driver != "taris")
throw std::runtime_error("Camera." + name + ".driver must be 'vimba' (got '" + throw std::runtime_error("Camera." + name + ".driver must be 'vimba' or 'taris' (got '" +
c.driver + "'); no other backend is implemented yet"); c.driver + "')");
if (c.enabled && c.id.empty()) if (c.enabled && c.id.empty())
throw std::runtime_error("Camera." + name + ".id is required (USB DEV_ id or GigE IP)"); throw std::runtime_error("Camera." + name + ".id is required (" +
if (c.profile_dir.empty()) (c.thermal() ? "the camera's serial number"
: "USB DEV_ id or GigE IP") + ")");
if (!c.thermal() && c.profile_dir.empty())
throw std::runtime_error("Camera." + name + ".profile_dir is required"); throw std::runtime_error("Camera." + name + ".profile_dir is required");
if (c.thermal()) {
if (c.jxl_distance != 0.0)
throw std::runtime_error("Camera." + name + ".jxl_distance must be 0: a thermal "
"camera's counts are always saved lossless");
if (c.calib_profile < 0)
throw std::runtime_error("Camera." + name + ".calib_profile must be >= 0");
if (!(c.frame_rate_hz > 0.0 && c.frame_rate_hz <= 50.0))
throw std::runtime_error("Camera." + name + ".frame_rate_hz must be in (0, 50]");
if (c.max_frame_age_ms <= 0)
throw std::runtime_error("Camera." + name + ".max_frame_age_ms must be > 0");
if (c.library.empty())
throw std::runtime_error("Camera." + name + ".library must not be empty");
}
if (c.jxl_distance < 0.0) if (c.jxl_distance < 0.0)
throw std::runtime_error("Camera." + name + ".jxl_distance must be >= 0 (0 = lossless)"); throw std::runtime_error("Camera." + name + ".jxl_distance must be >= 0 (0 = lossless)");
if (c.jxl_effort < 1 || c.jxl_effort > 9) if (c.jxl_effort < 1 || c.jxl_effort > 9)
@ -431,6 +454,27 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
} }
cfg.camera.cameras.push_back(std::move(c)); cfg.camera.cameras.push_back(std::move(c));
} }
// A thermal camera takes the newest frame it already has, so it needs no
// settle wait of its own; without a timing block it must not hold the scan
// to the 1000 ms default (the scan waits for the longest camera).
for (auto& c : cfg.camera.cameras)
if (c.thermal() && c.timing.find(c.profile) == c.timing.end())
c.timing[c.profile] = CameraProfileTiming{0, 0, "fixed"};
// Colour cameras first (see CameraConfig::cameras): the keep rule reads camera
// 0, and a thermal camera has none of the features it needs.
std::stable_partition(cfg.camera.cameras.begin(), cfg.camera.cameras.end(),
[](const CameraSpec& c) { return !c.thermal(); });
// The label is the output folder, and the cameras of one picture share its
// timestamp: two with one label would write the same file.
for (size_t i = 0; i < cfg.camera.cameras.size(); ++i)
for (size_t j = i + 1; j < cfg.camera.cameras.size(); ++j) {
const auto& a = cfg.camera.cameras[i];
const auto& b = cfg.camera.cameras[j];
if (a.enabled && b.enabled && a.label == b.label)
throw std::runtime_error("Camera." + a.name + " and Camera." + b.name +
" both have label '" + a.label +
"': each camera needs its own (its output folder)");
}
std::string out = get(kv, "Paths.output_dir"); std::string out = get(kv, "Paths.output_dir");
cfg.paths.output_dir = out.empty() ? paths::defaultOutputDir() : paths::expandUser(out); cfg.paths.output_dir = out.empty() ? paths::defaultOutputDir() : paths::expandUser(out);
@ -770,6 +814,7 @@ AppConfig ConfigLoader::loadFromFile(const std::string& path, const std::string&
} }
// Same for each camera's profile directory. // Same for each camera's profile directory.
for (auto& c : cfg.camera.cameras) { for (auto& c : cfg.camera.cameras) {
if (c.profile_dir.empty()) continue; // a thermal camera has none
std::filesystem::path pd(paths::expandUser(c.profile_dir)); std::filesystem::path pd(paths::expandUser(c.profile_dir));
if (pd.is_relative()) pd = std::filesystem::path(path).parent_path() / pd; if (pd.is_relative()) pd = std::filesystem::path(path).parent_path() / pd;
c.profile_dir = pd.lexically_normal().string(); c.profile_dir = pd.lexically_normal().string();

View File

@ -49,10 +49,17 @@ const std::vector<HelpSection>& helpCatalog() {
"setup or test."}}, "setup or test."}},
{"set camera profile [<camera>] <name>", "Switch a camera's VimbaX settings profile.", { {"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", "<name> is an XML stem in that camera's profile_dir. The camera may be",
"omitted when only one is configured.", "omitted when only one colour camera is configured. For the thermal",
"camera it only picks the timing block.",
"Example: set camera profile night"}}, "Example: set camera profile night"}},
{"camera reset", "Reboot the cameras and restore their profiles.", { {"camera reset [<camera>]", "Reboot the cameras and restore their profiles.", {
"For a stream that has stopped and will not restart. Takes seconds."}}, "For a stream that has stopped and will not restart. Takes seconds.",
"Name one camera (block name or label) to reset only that one."}},
{"camera calib [<profile>]", "The thermal camera's calibration profile.", {
"Bare: which profile, and its K per count. With a number: switch (about",
"3 s, no thermal frames meanwhile). Profile 0 is -40..120 degC at",
"0.01 K per count, 1 is 0..600 degC at 0.02 K. Every CamEvent says",
"which one its image was taken on (cal)."}},
}}, }},
{"Positioning", "Yaw is a true bearing (0 = north); pitch is elevation (0 = level).", { {"Positioning", "Yaw is a true bearing (0 = north); pitch is elevation (0 = level).", {
{"gimbal move <yaw>[,<pitch>]", "Point at a bearing/elevation in degrees.", { {"gimbal move <yaw>[,<pitch>]", "Point at a bearing/elevation in degrees.", {

View File

@ -1,5 +1,7 @@
#include "fgc/RawImage.h" #include "fgc/RawImage.h"
#include <cstring>
namespace fgc { namespace fgc {
const char* layoutName(PixelLayout layout) { const char* layoutName(PixelLayout layout) {
@ -8,6 +10,7 @@ const char* layoutName(PixelLayout layout) {
case PixelLayout::RGB8: return "RGB8"; case PixelLayout::RGB8: return "RGB8";
case PixelLayout::BayerRG8: return "BayerRG8"; case PixelLayout::BayerRG8: return "BayerRG8";
case PixelLayout::BayerRG12p: return "BayerRG12p"; case PixelLayout::BayerRG12p: return "BayerRG12p";
case PixelLayout::Mono16: return "Mono16";
case PixelLayout::Unknown: break; case PixelLayout::Unknown: break;
} }
return "Unknown"; return "Unknown";
@ -15,7 +18,7 @@ const char* layoutName(PixelLayout layout) {
PixelLayout layoutFromName(const std::string& name) { PixelLayout layoutFromName(const std::string& name) {
for (PixelLayout l : {PixelLayout::Mono8, PixelLayout::RGB8, PixelLayout::BayerRG8, for (PixelLayout l : {PixelLayout::Mono8, PixelLayout::RGB8, PixelLayout::BayerRG8,
PixelLayout::BayerRG12p}) PixelLayout::BayerRG12p, PixelLayout::Mono16})
if (name == layoutName(l)) return l; if (name == layoutName(l)) return l;
return PixelLayout::Unknown; return PixelLayout::Unknown;
} }
@ -30,6 +33,7 @@ int layoutBits(PixelLayout layout) {
case PixelLayout::RGB8: case PixelLayout::RGB8:
case PixelLayout::BayerRG8: return 8; case PixelLayout::BayerRG8: return 8;
case PixelLayout::BayerRG12p: return 12; case PixelLayout::BayerRG12p: return 12;
case PixelLayout::Mono16: return 16;
case PixelLayout::Unknown: break; case PixelLayout::Unknown: break;
} }
return 0; return 0;
@ -42,6 +46,7 @@ size_t layoutFrameBytes(PixelLayout layout, uint32_t width, uint32_t height) {
case PixelLayout::BayerRG8: return n; case PixelLayout::BayerRG8: return n;
case PixelLayout::RGB8: return n * 3; case PixelLayout::RGB8: return n * 3;
case PixelLayout::BayerRG12p: return (n * 3 + 1) / 2; case PixelLayout::BayerRG12p: return (n * 3 + 1) / 2;
case PixelLayout::Mono16: return n * 2;
case PixelLayout::Unknown: break; case PixelLayout::Unknown: break;
} }
return 0; return 0;
@ -49,6 +54,12 @@ size_t layoutFrameBytes(PixelLayout layout, uint32_t width, uint32_t height) {
namespace { namespace {
inline uint16_t px16(const uint8_t* in, size_t i) {
uint16_t v;
std::memcpy(&v, in + i * 2, sizeof v); // host order, possibly unaligned
return v;
}
inline uint16_t px12p(const uint8_t* in, size_t i) { inline uint16_t px12p(const uint8_t* in, size_t i) {
const uint8_t* p = in + (i >> 1) * 3; const uint8_t* p = in + (i >> 1) * 3;
return (i & 1) == 0 ? static_cast<uint16_t>(p[0] | ((p[1] & 0x0F) << 8)) return (i & 1) == 0 ? static_cast<uint16_t>(p[0] | ((p[1] & 0x0F) << 8))
@ -79,7 +90,9 @@ BrightnessSample sampleBrightness(const uint8_t* frame, size_t bytes, PixelLayou
double sum = 0; double sum = 0;
size_t hi = 0; size_t hi = 0;
for (size_t i = 0; i < values; i += stride) { for (size_t i = 0; i < values; i += stride) {
double v = layout == PixelLayout::BayerRG12p ? px12p(frame, i) / 16.0 : frame[i]; double v = layout == PixelLayout::BayerRG12p ? px12p(frame, i) / 16.0
: layout == PixelLayout::Mono16 ? px16(frame, i) / 256.0
: frame[i];
sum += v; sum += v;
hi += v >= 250.0; hi += v >= 250.0;
++s.samples; ++s.samples;

View File

@ -1298,8 +1298,12 @@ void TestRunner::testCamera(TestReport& r) {
f.channels != run.channels) { f.channels != run.channels) {
run.consistent = false; run.consistent = false;
} }
if (f.data.size() != static_cast<size_t>(f.width) * f.height * f.channels) // By layout where it is known: packed 12-bit and 16-bit counts are
run.consistent = false; // not one byte per value.
const size_t expect = f.layout != PixelLayout::Unknown
? layoutFrameBytes(f.layout, f.width, f.height)
: static_cast<size_t>(f.width) * f.height * f.channels;
if (f.data.size() != expect) run.consistent = false;
++run.got; ++run.got;
} }
const double secs = std::chrono::duration<double>( const double secs = std::chrono::duration<double>(
@ -1316,6 +1320,17 @@ void TestRunner::testCamera(TestReport& r) {
if (!want_dark && !want_bright) continue; if (!want_dark && !want_bright) continue;
// A thermal camera has no exposure to force: dark and bright are checks
// for a colour camera.
{
double elo = 0, ehi = 0;
if (!cam->featureRange(i, "ExposureTime", elo, ehi)) {
if (want_dark) sd.notes.push_back(cn + ": not applicable (no exposure control)");
if (want_bright) sb.notes.push_back(cn + ": not applicable (no exposure control)");
continue;
}
}
// From here the camera is off its profile. It is put back below on every // From here the camera is off its profile. It is put back below on every
// path out of these phases, and a failure to put it back is a failure of // path out of these phases, and a failure to put it back is a failure of
// the test. // the test.

51
src/core/ThermalStats.cpp Normal file
View File

@ -0,0 +1,51 @@
#include "fgc/ThermalStats.h"
#include <cmath>
#include <vector>
namespace fgc {
int countsToDeciC(uint32_t count, double kelvin_per_count) {
return static_cast<int>(std::lround((count * kelvin_per_count - 273.15) * 10.0));
}
ThermalStats thermalStats(const uint16_t* counts, uint32_t width, uint32_t height,
const ThermalFrameInfo& info) {
ThermalStats s;
if (!counts || width == 0 || height == 0) return s;
// A histogram gives the median in one pass with no copy of the frame, and
// 64k bins are cheaper than sorting 300k values.
std::vector<uint32_t> hist(65536, 0);
const size_t n = static_cast<size_t>(width) * height;
size_t at = 0;
uint16_t max = 0;
for (size_t i = 0; i < n; ++i) {
const uint16_t c = counts[i];
if (c == 0 || c > info.valid_count_max) continue;
++hist[c];
++s.valid_pixels;
if (c > max) { max = c; at = i; }
}
if (s.valid_pixels == 0) return s;
// The lower median: the ((n-1)/2)-th value in sorted order.
const size_t want = (s.valid_pixels - 1) / 2;
size_t seen = 0;
uint32_t med = 0;
for (uint32_t c = 1; c < hist.size(); ++c) {
seen += hist[c];
if (seen > want) { med = c; break; }
}
s.valid = true;
s.max_count = max;
s.median_count = static_cast<uint16_t>(med);
s.tmax_decic = countsToDeciC(max, info.kelvin_per_count);
s.tmed_decic = countsToDeciC(med, info.kelvin_per_count);
s.hotx = (static_cast<double>(at % width) + 0.5) / width;
s.hoty = (static_cast<double>(at / width) + 0.5) / height;
return s;
}
} // namespace fgc

View File

@ -379,6 +379,15 @@ Element sensorsPanel(const SensorsView& s) {
group(s.host, std::move(cpu_badge))})); group(s.host, std::move(cpu_badge))}));
} }
// "max 301.5 °C median 18.7 °C cal 0" for the last thermal picture.
Element thermalLine(const CaptureView& c) {
if (!c.thermal_stats_ok)
return text("no valid pixels cal " + std::to_string(c.thermal_cal)) | color(Color::Yellow);
return hbox({text("max " + num(c.thermal_max_c, 1) + " \xC2\xB0" "C") | bold,
text(" median " + num(c.thermal_med_c, 1) + " \xC2\xB0" "C") | dim,
text(" cal " + std::to_string(c.thermal_cal)) | dim});
}
Element cameraPanel(const CaptureView& c) { Element cameraPanel(const CaptureView& c) {
std::string labels; std::string labels;
for (size_t i = 0; i < c.labels.size(); ++i) for (size_t i = 0; i < c.labels.size(); ++i)
@ -417,6 +426,12 @@ Element cameraPanel(const CaptureView& c) {
} else { } else {
rows.push_back(labelRow("last", text("\xE2\x80\x94") | dim)); rows.push_back(labelRow("last", text("\xE2\x80\x94") | dim));
} }
if (c.has_thermal) rows.push_back(labelRow("thermal", thermalLine(c)));
for (size_t i = 0; i < c.devices.size(); ++i)
if (!c.devices[i].offline.empty())
rows.push_back(labelRow("offline", text((i < c.labels.size() ? c.labels[i] : "?") +
" " + c.devices[i].offline) |
color(Color::Yellow)));
return panel("[c] CAMERAS", Color::Blue, vbox(std::move(rows))); return panel("[c] CAMERAS", Color::Blue, vbox(std::move(rows)));
} }
@ -442,12 +457,15 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
for (size_t i = 0; i < c.devices.size(); ++i) { for (size_t i = 0; i < c.devices.size(); ++i) {
const CameraDeviceView& d = c.devices[i]; const CameraDeviceView& d = c.devices[i];
std::string label = i < c.labels.size() ? c.labels[i] : ("cam" + std::to_string(i)); std::string label = i < c.labels.size() ? c.labels[i] : ("cam" + std::to_string(i));
Element st = d.streaming ? (text("streaming") | color(Color::Green)) Element st = !d.offline.empty() ? (text("offline") | color(Color::Red))
: d.streaming ? (text("streaming") | color(Color::Green))
: (text("stopped") | color(Color::Yellow)); : (text("stopped") | color(Color::Yellow));
rows.push_back(hbox({text(label + " ") | bold, rows.push_back(hbox({text(label + " ") | bold,
text(d.model.empty() ? "?" : d.model) | color(Color::Cyan), text(d.model.empty() ? "?" : d.model) | color(Color::Cyan),
text(" " + d.id) | dim, filler(), st})); text(" " + d.id) | dim, filler(), st}));
rows.push_back(row(" serial", text(d.serial.empty() ? "—" : d.serial) | dim)); rows.push_back(row(" serial", text(d.serial.empty() ? "—" : d.serial) | dim));
if (!d.offline.empty())
rows.push_back(row(" offline", text(d.offline) | color(Color::Red)));
rows.push_back(row(" frame", text(std::to_string(d.width) + "x" + std::to_string(d.height) + rows.push_back(row(" frame", text(std::to_string(d.width) + "x" + std::to_string(d.height) +
" " + num(d.payload_bytes / 1048576.0, 1) + " MB"))); " " + num(d.payload_bytes / 1048576.0, 1) + " MB")));
rows.push_back(row(" exposure", text(num(d.exposure_us, 0) + " us gain " + rows.push_back(row(" exposure", text(num(d.exposure_us, 0) + " us gain " +
@ -465,8 +483,17 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
// --- Cameras: one block each, everything explicit --- // --- Cameras: one block each, everything explicit ---
const CameraConfigView& cf = c.config; const CameraConfigView& cf = c.config;
rows.push_back(separator()); rows.push_back(separator());
{
// Which backends: "Vimba X", "Vimba X + TarisIR".
std::string backends;
auto add = [&backends](const std::string& b) {
if (backends.find(b) == std::string::npos) backends += (backends.empty() ? "" : " + ") + b;
};
for (const auto& v : cf.cameras)
if (v.enabled) add(v.driver == "taris" ? "TarisIR" : "Vimba X");
rows.push_back(hbox({text("CAMERAS") | bold | color(Color::Blue), rows.push_back(hbox({text("CAMERAS") | bold | color(Color::Blue),
text(cf.mock ? " MOCK (simulated)" : " Vimba X") | dim})); text(cf.mock ? " MOCK (simulated)" : " " + backends) | dim}));
}
if (cf.cameras.empty()) rows.push_back(text("(no cameras configured)") | dim); if (cf.cameras.empty()) rows.push_back(text("(no cameras configured)") | dim);
for (const auto& v : cf.cameras) { for (const auto& v : cf.cameras) {
rows.push_back(hbox({ rows.push_back(hbox({
@ -486,7 +513,11 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
} }
rows.push_back(row(" settle", text(std::to_string(v.settle_wait_ms) + rows.push_back(row(" settle", text(std::to_string(v.settle_wait_ms) +
" ms after standstill before keeping a frame"))); " ms after standstill before keeping a frame")));
rows.push_back(row(" encoding", text("JPEG XL distance " + num(v.jxl_distance, 2) + rows.push_back(row(" encoding",
text(v.driver == "taris"
? "JPEG XL lossless 16-bit counts effort " +
std::to_string(v.jxl_effort)
: "JPEG XL distance " + num(v.jxl_distance, 2) +
" effort " + std::to_string(v.jxl_effort)))); " effort " + std::to_string(v.jxl_effort))));
rows.push_back(row(" rotation", text(v.rotation_deg == 0 rows.push_back(row(" rotation", text(v.rotation_deg == 0
? std::string("none") ? std::string("none")
@ -503,6 +534,7 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
formatDegrees(c.last_pitch_deg) + " " + formatDegrees(c.last_pitch_deg) + " " +
formatTimeAgo(now, c.last_ts_ms)))); formatTimeAgo(now, c.last_ts_ms))));
} }
if (c.has_thermal) rows.push_back(row("last thermal", thermalLine(c)));
// --- Scan grid --- // --- Scan grid ---
rows.push_back(separator()); rows.push_back(separator());

View File

@ -58,6 +58,9 @@ add_executable(fgc_tests
test_hostmetrics.cpp test_hostmetrics.cpp
test_testrunner.cpp test_testrunner.cpp
test_rawimage.cpp test_rawimage.cpp
test_thermalstats.cpp
test_composite.cpp
test_tariscamerasource.cpp
test_settlerule.cpp test_settlerule.cpp
test_thermalguard.cpp test_thermalguard.cpp
) )

View File

@ -1,5 +1,7 @@
#include <doctest/doctest.h> #include <doctest/doctest.h>
#include <clocale>
#include "fgc/ChannelHistory.h" #include "fgc/ChannelHistory.h"
#include "fgc/ChannelTopics.h" #include "fgc/ChannelTopics.h"
@ -82,4 +84,52 @@ TEST_CASE("topic and payload builders match the documented wire format") {
CHECK(j.find("\"time\":1719312345678") != std::string::npos); CHECK(j.find("\"time\":1719312345678") != std::string::npos);
// The quality gate is gone; nothing should still be publishing a verdict. // The quality gate is gone; nothing should still be publishing a verdict.
CHECK(j.find("degraded") == std::string::npos); CHECK(j.find("degraded") == std::string::npos);
// A colour camera's event carries none of the thermal fields.
CHECK(j.find("\"cal\"") == std::string::npos);
CHECK(j == "{ \"fwt\":\"Tower\", \"cam\":\"RGB\", \"hdg\":1373, \"pit\":300, "
"\"time\":1719312345678 }");
}
TEST_CASE("a thermal CamEvent carries profile, packets, temperatures and hot spot") {
CamEvent c;
c.tower = "Tower"; c.camera = "TIR";
c.heading_decideg = 1234; c.pitch_decideg = -30; c.timestamp_ms = 1791234567890LL;
ThermalEventFields t;
t.cal = 0; t.missed = 2; t.stats_ok = true;
t.tmax_decic = 3015; t.tmed_decic = 187; t.hotx = 0.73594; t.hoty = 0.5833;
c.thermal = t;
CHECK(camEventJson(c) ==
"{ \"fwt\":\"Tower\", \"cam\":\"TIR\", \"hdg\":1234, \"pit\":-30, "
"\"time\":1791234567890, \"cal\":0, \"missed\":2, \"tmax\":3015, \"tmed\":187, "
"\"hotx\":0.7359, \"hoty\":0.5833 }");
// Without valid pixels the profile and packets still go out, the rest as null.
c.thermal->stats_ok = false;
const std::string j = camEventJson(c);
CHECK(j.find("\"cal\":0, \"missed\":2, \"tmax\":null, \"tmed\":null, \"hotx\":null, "
"\"hoty\":null }") != std::string::npos);
}
TEST_CASE("numbers in the JSON keep their '.' under a comma locale") {
// The thermal camera's SDK switches the C locale to one with a decimal comma.
const std::string before = std::setlocale(LC_NUMERIC, nullptr);
const bool comma = std::setlocale(LC_NUMERIC, "de_DE.UTF-8") != nullptr;
CamEvent c;
c.tower = "T"; c.camera = "TIR";
ThermalEventFields t;
t.stats_ok = true; t.hotx = 0.43359; t.hoty = 1.0;
c.thermal = t;
const std::string j = camEventJson(c);
std::setlocale(LC_NUMERIC, before.c_str());
CHECK(j.find("\"hotx\":0.4336, \"hoty\":1.0000") != std::string::npos);
if (!comma) MESSAGE("no de_DE locale here: checked under the C locale only");
HealthReport h;
h.interval_s = -2.25;
CHECK(healthJson(h).find("\"interval_s\":-2.3") != std::string::npos); // rounds half away
}
TEST_CASE("CamEvent names are escaped") {
CamEvent c;
c.tower = "a\"b"; c.camera = "RGB";
CHECK(camEventJson(c).find("\"fwt\":\"a\\\"b\"") != std::string::npos);
} }

195
tests/test_composite.cpp Normal file
View File

@ -0,0 +1,195 @@
#include "fgc/CompositeCameraSource.h"
#include <doctest/doctest.h>
#include <stdexcept>
#include <vector>
using namespace fgc;
namespace {
// A source of `count` cameras whose trigger() delivers one frame per camera,
// stamped `ts`, when `ready` - and reports nothing otherwise.
struct FakeSource : ICameraSource {
int count;
long long ts;
bool counts_after_open = false; // as Vimba: no cameras until open()
bool opened = false;
bool ready = true;
bool throws_on_open = false;
int triggers = 0;
bool started = false;
std::string why_offline;
FrameCallback cb;
FakeSource(int n, long long t) : count(n), ts(t) {}
void open() override {
if (throws_on_open) throw std::runtime_error("no camera");
opened = true;
}
void close() override {}
void start() override { started = true; }
void stop() override { started = false; }
bool trigger() override {
++triggers;
if (!ready) return false;
for (int i = 0; i < count; ++i) {
Frame f;
f.cam_id = i;
f.timestamp_ms = ts + i;
cb(f);
}
return true;
}
void setFrameCallback(FrameCallback c) override { cb = std::move(c); }
int cameraCount() const override { return counts_after_open && !opened ? 0 : count; }
bool readFeature(int i, const std::string& name, double& v) override {
v = i * 100 + ts;
return name == "X";
}
std::string offline(int) override { return why_offline; }
std::vector<CameraDeviceInfo> deviceInfo() override {
std::vector<CameraDeviceInfo> out(count);
for (int i = 0; i < count; ++i) out[i].id = std::to_string(ts) + ":" + std::to_string(i);
return out;
}
bool grabFrame(int i, Frame& out, int) override {
out = Frame{};
out.cam_id = i;
out.timestamp_ms = ts + 50;
return true;
}
};
struct Rig {
FakeSource* colour = nullptr;
FakeSource* thermal = nullptr;
std::unique_ptr<CompositeCameraSource> src;
std::vector<Frame> frames;
explicit Rig(int colour_count = 1) {
auto c = std::make_unique<FakeSource>(colour_count, 1000);
auto t = std::make_unique<FakeSource>(1, 5000);
colour = c.get();
thermal = t.get();
std::vector<std::unique_ptr<ICameraSource>> v;
v.push_back(std::move(c));
v.push_back(std::move(t));
src = std::make_unique<CompositeCameraSource>(std::move(v));
src->setFrameCallback([this](const Frame& f) { frames.push_back(f); });
}
};
} // namespace
TEST_CASE("the composite numbers the cameras leader first, and counts them all") {
Rig r(2);
CHECK(r.src->cameraCount() == 3);
REQUIRE(r.src->trigger());
REQUIRE(r.frames.size() == 3);
CHECK(r.frames[0].cam_id == 0);
CHECK(r.frames[1].cam_id == 1);
CHECK(r.frames[2].cam_id == 2); // the thermal camera
}
TEST_CASE("one trigger gives every frame the leader's time") {
Rig r;
REQUIRE(r.src->trigger());
REQUIRE(r.frames.size() == 2);
CHECK(r.frames[0].timestamp_ms == 1000);
CHECK(r.frames[1].timestamp_ms == 1000); // not its own 5000
}
TEST_CASE("the followers are asked only once the leader has delivered") {
Rig r;
r.colour->ready = false;
CHECK_FALSE(r.src->trigger());
CHECK_FALSE(r.src->trigger());
CHECK(r.thermal->triggers == 0); // no thermal picture without its colour one
CHECK(r.frames.empty());
r.colour->ready = true;
CHECK(r.src->trigger());
CHECK(r.thermal->triggers == 1);
}
TEST_CASE("a follower with nothing to give costs only its own picture") {
Rig r;
r.thermal->ready = false;
CHECK(r.src->trigger());
REQUIRE(r.frames.size() == 1);
CHECK(r.frames[0].cam_id == 0);
}
TEST_CASE("calls about one camera reach the source that has it") {
Rig r(2);
double v = 0;
REQUIRE(r.src->readFeature(1, "X", v));
CHECK(v == 1100); // colour source, its camera 1
REQUIRE(r.src->readFeature(2, "X", v));
CHECK(v == 5000); // thermal source, its camera 0
CHECK_FALSE(r.src->readFeature(3, "X", v));
r.thermal->why_offline = "unplugged";
CHECK(r.src->offline(0).empty());
CHECK(r.src->offline(2) == "unplugged");
const auto d = r.src->deviceInfo();
REQUIRE(d.size() == 3);
CHECK(d[2].id == "5000:0");
// A grab outside a trigger keeps its own time, under the global index.
Frame f;
REQUIRE(r.src->grabFrame(2, f, 10));
CHECK(f.cam_id == 2);
CHECK(f.timestamp_ms == 5050);
}
TEST_CASE("the indices are the configured ones, also before the leader has opened") {
// On tower 111 the thermal frame came out as camera 0 - "RGB", overwriting
// the colour file of the same time - because Vimba counts no camera before
// open() and the offsets were taken from that.
auto c = std::make_unique<FakeSource>(1, 1000);
auto t = std::make_unique<FakeSource>(1, 5000);
c->counts_after_open = true;
FakeSource* colour = c.get();
std::vector<std::unique_ptr<ICameraSource>> v;
v.push_back(std::move(c));
v.push_back(std::move(t));
CompositeCameraSource src(std::move(v), {1, 1});
std::vector<Frame> frames;
src.setFrameCallback([&](const Frame& f) { frames.push_back(f); });
CHECK(src.cameraCount() == 1); // what is open: the thermal camera only
src.open();
CHECK(colour->opened);
CHECK(src.cameraCount() == 2);
REQUIRE(src.trigger());
REQUIRE(frames.size() == 2);
CHECK(frames[0].cam_id == 0);
CHECK(frames[1].cam_id == 1);
CHECK(src.offline(1).empty());
CHECK_THROWS(CompositeCameraSource(std::vector<std::unique_ptr<ICameraSource>>{}, {}));
}
TEST_CASE("start and stop reach every source") {
Rig r;
r.src->start();
CHECK(r.colour->started);
CHECK(r.thermal->started);
r.src->stop();
CHECK_FALSE(r.thermal->started);
}
TEST_CASE("a follower that will not open does not stop the run; the leader does") {
{
Rig r;
r.thermal->throws_on_open = true;
CHECK_NOTHROW(r.src->open());
}
{
Rig r;
r.colour->throws_on_open = true;
CHECK_THROWS(r.src->open());
}
}

View File

@ -275,7 +275,7 @@ TEST_CASE("Camera blocks reject what cannot work") {
}; };
CHECK_NOTHROW(ConfigLoader::fromMap(base({}))); CHECK_NOTHROW(ConfigLoader::fromMap(base({})));
// No second backend exists yet; accepting the key silently would be worse. // An unknown backend; accepting the key silently would be worse.
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.driver", "thermal"}}))); CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.driver", "thermal"}})));
// An enabled camera with no id cannot be opened. // An enabled camera with no id cannot be opened.
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rgb.profile_dir", "/tmp/p"}})); CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rgb.profile_dir", "/tmp/p"}}));
@ -288,6 +288,93 @@ TEST_CASE("Camera blocks reject what cannot work") {
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.standard.settle_wait", "500"}}))); CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.standard.settle_wait", "500"}})));
} }
TEST_CASE("A thermal camera needs no settings XML and is always lossless") {
AppConfig c = ConfigLoader::fromMap({
{"Camera.jxl_distance", "0.8"},
{"Camera.rgb.id", "DEV_1"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{"Camera.tir.driver", "taris"},
{"Camera.tir.id", "2017744"},
{"Camera.tir.label", "TIR"},
{"Camera.tir.calib_profile", "1"},
{"Camera.tir.frame_rate_hz", "9"},
{"Camera.tir.interface", "enp1s0"},
{"Camera.tir.standard.settle_wait_ms", "200"},
});
REQUIRE(c.camera.cameras.size() == 2);
const CameraSpec& tir = c.camera.cameras[1];
CHECK(tir.thermal());
CHECK_FALSE(c.camera.cameras[0].thermal());
CHECK(tir.label == "TIR");
CHECK(tir.profile_dir.empty());
CHECK(tir.calib_profile == 1);
CHECK(tir.frame_rate_hz == doctest::Approx(9.0));
CHECK(tir.max_frame_age_ms == 400);
CHECK(tir.library == "libirbgrab_x86_64-linux.so");
CHECK(tir.interface == "enp1s0");
// [Camera] jxl_distance is for colour cameras; counts default to lossless.
CHECK(tir.jxl_distance == 0.0);
CHECK(c.camera.cameras[0].jxl_distance == doctest::Approx(0.8));
CHECK(c.camera.maxSettleWaitMs() == 1000); // the colour camera's default is longer
}
TEST_CASE("A thermal camera without a timing block does not slow the scan") {
AppConfig c = ConfigLoader::fromMap({
{"Camera.rgb.id", "DEV_1"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{"Camera.rgb.standard.settle_wait_ms", "500"},
{"Camera.rgb.standard.frame_timeout_ms", "700"},
{"Camera.tir.driver", "taris"},
{"Camera.tir.id", "2017744"},
});
CHECK(c.camera.maxSettleWaitMs() == 500);
CHECK(c.camera.maxFrameTimeoutMs() == 700);
}
TEST_CASE("Colour cameras come first whatever the block names") {
// Camera 0 is the one the keep rule reads; a thermal camera cannot be it.
AppConfig c = ConfigLoader::fromMap({
{"Camera.a_tir.driver", "taris"},
{"Camera.a_tir.id", "2017744"},
{"Camera.rgb.id", "DEV_1"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{"Camera.nir.id", "DEV_2"},
{"Camera.nir.profile_dir", "/tmp/p"},
});
REQUIRE(c.camera.cameras.size() == 3);
CHECK(c.camera.cameras[0].name == "nir");
CHECK(c.camera.cameras[1].name == "rgb");
CHECK(c.camera.cameras[2].name == "a_tir");
}
TEST_CASE("Thermal camera blocks reject what cannot work") {
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"},
{"Camera.tir.driver", "taris"},
{"Camera.tir.id", "2017744"}};
for (auto& [k, v] : extra) kv[k] = v;
return kv;
};
CHECK_NOTHROW(ConfigLoader::fromMap(base({})));
// Counts are measurements: lossy coding would change them.
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.tir.jxl_distance", "0.5"}})));
CHECK_NOTHROW(ConfigLoader::fromMap(base({{"Camera.tir.jxl_distance", "0"}})));
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.tir.id", ""}})));
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.tir.calib_profile", "-1"}})));
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.tir.frame_rate_hz", "0"}})));
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.tir.frame_rate_hz", "60"}})));
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.tir.max_frame_age_ms", "0"}})));
// An empty value means the default, as for every key.
CHECK(ConfigLoader::fromMap(base({{"Camera.tir.library", ""}})).camera.cameras[1].library ==
"libirbgrab_x86_64-linux.so");
// Two cameras, one output folder: their pictures would overwrite each other.
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.label", "X"}, {"Camera.tir.label", "X"}})));
// A disabled thermal camera needs no serial, like any other.
CHECK_NOTHROW(ConfigLoader::fromMap(
base({{"Camera.tir.id", ""}, {"Camera.tir.enabled", "false"}})));
}
TEST_CASE("updateIniSectionKeys replaces in-section keys, leaving others intact") { TEST_CASE("updateIniSectionKeys replaces in-section keys, leaving others intact") {
const std::string in = const std::string in =
"[General]\n" "[General]\n"

View File

@ -5,6 +5,7 @@
#include <doctest/doctest.h> #include <doctest/doctest.h>
#include <chrono> #include <chrono>
#include <cstring>
#include <filesystem> #include <filesystem>
#include <mutex> #include <mutex>
#include <thread> #include <thread>
@ -65,6 +66,24 @@ Frame bayerFrame(PixelLayout layout, uint32_t w, uint32_t h, int r, int g, int b
return f; return f;
} }
// A thermal frame of counts: `room` everywhere, `hot` at (hx, hy).
Frame thermalFrame(uint32_t w, uint32_t h, uint16_t room, uint16_t hot, uint32_t hx, uint32_t hy,
long long ts, bool with_info = true) {
Frame f;
f.width = w; f.height = h; f.layout = PixelLayout::Mono16; f.channels = 1; f.timestamp_ms = ts;
std::vector<uint16_t> px(static_cast<size_t>(w) * h, room);
px[static_cast<size_t>(hy) * w + hx] = hot;
f.data.resize(px.size() * 2);
std::memcpy(f.data.data(), px.data(), f.data.size());
if (with_info) {
ThermalFrameInfo info;
info.cal = 0;
info.missed_packets = 3;
f.thermal = info;
}
return f;
}
bool waitSaved(const ImagePipeline& p, long long n, int ms = 20000) { bool waitSaved(const ImagePipeline& p, long long n, int ms = 20000) {
const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms); const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms);
while (p.imagesSaved() < n && std::chrono::steady_clock::now() < end) while (p.imagesSaved() < n && std::chrono::steady_clock::now() < end)
@ -167,6 +186,86 @@ TEST_CASE("rotation applies to raw frames too") {
CHECK(interiorMean(d, 48, 64, 0) == doctest::Approx(3200).epsilon(0.01)); CHECK(interiorMean(d, 48, 64, 0) == doctest::Approx(3200).epsilon(0.01));
} }
TEST_CASE("thermal counts are saved 16-bit lossless with their temperatures in the event") {
TempDir t;
FakeChannel ch;
auto p = params(t, 0.8); // a lossy setting must not reach the counts
p.cameras = {{"TIR", 0, 0.8, 1, true}};
ImagePipeline pipe(ch, [] { return Orientation{123.4f, -3.0f}; }, p);
pipe.start();
const Frame f = thermalFrame(64, 48, 29315, 57315, 40, 12, 4242);
pipe.submit(f);
REQUIRE(waitSaved(pipe, 1));
pipe.stop();
const std::string file = onlyFile(t);
CHECK(fs::path(file).parent_path().filename() == "TIR");
CHECK(fs::path(file).filename() == "4242.jxl");
const auto d = decode(file, 1);
CHECK(d.bits == 16);
CHECK(d.channels == 1);
REQUIRE(d.px.size() == 64u * 48);
CHECK(std::memcmp(d.px.data(), f.data.data(), f.data.size()) == 0); // bit-exact
REQUIRE(ch.count() == 1);
const CamEvent& e = ch.events[0];
CHECK(e.camera == "TIR");
CHECK(e.heading_decideg == 1234);
REQUIRE(e.thermal);
CHECK(e.thermal->cal == 0);
CHECK(e.thermal->missed == 3);
CHECK(e.thermal->stats_ok);
CHECK(e.thermal->tmax_decic == 3000);
CHECK(e.thermal->tmed_decic == 200);
CHECK(e.thermal->hotx == doctest::Approx(40.5 / 64));
CHECK(e.thermal->hoty == doctest::Approx(12.5 / 48));
}
TEST_CASE("the thermal hot spot is where it is in the stored, rotated image") {
TempDir t;
FakeChannel ch;
auto p = params(t);
p.cameras = {{"TIR", 90, 0.0, 1, true}};
ImagePipeline pipe(ch, nullptr, p);
pipe.start();
// 64 x 48, hot at (40, 12). Rotated 90 deg clockwise it is 48 x 64, and a
// pixel at (x, y) moves to (H-1-y, x) = (35, 40).
pipe.submit(thermalFrame(64, 48, 29315, 40000, 40, 12, 5));
REQUIRE(waitSaved(pipe, 1));
pipe.stop();
const auto d = decode(onlyFile(t), 1);
REQUIRE(d.px.size() == 48u * 64);
CHECK(d.px[40 * 48 + 35] == 40000);
REQUIRE(ch.events[0].thermal);
CHECK(ch.events[0].thermal->hotx == doctest::Approx(35.5 / 48));
CHECK(ch.events[0].thermal->hoty == doctest::Approx(40.5 / 64));
}
TEST_CASE("a Mono16 frame without thermal info is saved, but its event has no thermal fields") {
TempDir t;
FakeChannel ch;
ImagePipeline pipe(ch, nullptr, params(t));
pipe.start();
pipe.submit(thermalFrame(32, 16, 1000, 2000, 1, 1, 6, false));
REQUIRE(waitSaved(pipe, 1));
pipe.stop();
CHECK(decode(onlyFile(t), 1).bits == 16);
REQUIRE(ch.count() == 1);
CHECK_FALSE(ch.events[0].thermal);
}
TEST_CASE("an all-zero thermal frame is saved, its temperatures unknown") {
TempDir t;
FakeChannel ch;
ImagePipeline pipe(ch, nullptr, params(t));
pipe.start();
pipe.submit(thermalFrame(32, 16, 0, 0, 0, 0, 7));
REQUIRE(waitSaved(pipe, 1));
pipe.stop();
REQUIRE(ch.events[0].thermal);
CHECK_FALSE(ch.events[0].thermal->stats_ok);
}
TEST_CASE("several workers save every frame") { TEST_CASE("several workers save every frame") {
TempDir t; TempDir t;
FakeChannel ch; FakeChannel ch;

View File

@ -91,6 +91,21 @@ TEST_CASE("12-bit lossless decodes exactly, extremes included") {
} }
} }
TEST_CASE("16-bit single-channel lossless decodes exactly (thermal counts)") {
TempDir t;
// Counts around room temperature, plus the full range's ends.
auto src = image(96, 64, 1, 65535, 11);
for (size_t i = 0; i < src.size(); i += 3) src[i] = static_cast<uint16_t>(28000 + i % 2500);
src.front() = 0;
src.back() = 65535;
REQUIRE(JpegXlEncoder::encodeToFile(t.file("ll16.jxl"), src.data(), 96, 64, 1, 16, 0.0f, 1, 2));
const auto d = decode(t.file("ll16.jxl"), 1);
CHECK(d.bits == 16);
CHECK(d.channels == 1);
CHECK(d.original_profile);
CHECK(d.px == src);
}
TEST_CASE("lossy keeps the bit depth and stays close") { TEST_CASE("lossy keeps the bit depth and stays close") {
TempDir t; TempDir t;
SUBCASE("12-bit colour") { SUBCASE("12-bit colour") {

View File

@ -26,7 +26,7 @@ std::vector<uint8_t> pack12p(const std::vector<uint16_t>& v) {
TEST_CASE("layout names round-trip, and unknown formats are refused") { TEST_CASE("layout names round-trip, and unknown formats are refused") {
for (PixelLayout l : {PixelLayout::Mono8, PixelLayout::RGB8, PixelLayout::BayerRG8, for (PixelLayout l : {PixelLayout::Mono8, PixelLayout::RGB8, PixelLayout::BayerRG8,
PixelLayout::BayerRG12p}) PixelLayout::BayerRG12p, PixelLayout::Mono16})
CHECK(layoutFromName(layoutName(l)) == l); CHECK(layoutFromName(layoutName(l)) == l);
CHECK(layoutFromName("BayerRG12") == PixelLayout::Unknown); // unpacked: not handled CHECK(layoutFromName("BayerRG12") == PixelLayout::Unknown); // unpacked: not handled
CHECK(layoutFromName("YCbCr422_8_CbYCrY") == PixelLayout::Unknown); CHECK(layoutFromName("YCbCr422_8_CbYCrY") == PixelLayout::Unknown);
@ -45,6 +45,19 @@ TEST_CASE("layout properties") {
CHECK(layoutFrameBytes(PixelLayout::BayerRG8, 5328, 3040) == 16197120); CHECK(layoutFrameBytes(PixelLayout::BayerRG8, 5328, 3040) == 16197120);
CHECK(layoutFrameBytes(PixelLayout::RGB8, 5328, 3040) == 48591360); CHECK(layoutFrameBytes(PixelLayout::RGB8, 5328, 3040) == 48591360);
CHECK(layoutFrameBytes(PixelLayout::BayerRG12p, 3, 1) == 5); // odd pixel count rounds up CHECK(layoutFrameBytes(PixelLayout::BayerRG12p, 3, 1) == 5); // odd pixel count rounds up
// The thermal camera: 640 x 480 counts, two bytes each.
CHECK_FALSE(layoutIsBayer(PixelLayout::Mono16));
CHECK(layoutBits(PixelLayout::Mono16) == 16);
CHECK(layoutFrameBytes(PixelLayout::Mono16, 640, 480) == 614400);
}
TEST_CASE("brightness sample: 16-bit is reported on the 0..255 scale") {
std::vector<uint16_t> v(640 * 480, 0x8000);
const auto* b = reinterpret_cast<const uint8_t*>(v.data());
const auto s = sampleBrightness(b, v.size() * 2, PixelLayout::Mono16, 640, 480);
REQUIRE(s.valid());
CHECK(s.mean == doctest::Approx(128.0));
CHECK(s.clipped == doctest::Approx(0.0));
} }
TEST_CASE("unpack12p matches GenICam 12p, by hand") { TEST_CASE("unpack12p matches GenICam 12p, by hand") {

View File

@ -0,0 +1,385 @@
#include "fgc/TarisCameraSource.h"
#include <doctest/doctest.h>
#include <atomic>
#include <chrono>
#include <cstring>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
using namespace fgc;
using namespace fgc::irbgrab;
namespace {
// A stand-in for the IRBGRAB SDK and one TarisIR mini behind it. The source
// calls it from its worker thread; the test changes it from its own.
struct FakeSdk {
std::mutex m;
std::vector<std::string> search{"SN:2017744|00:0e:c6:aa:bb:cc|192.168.0.200#0"};
std::string accept = "#0"; // connect succeeds for strings ending like this
std::vector<std::string> connect_attempts;
int cal = 0;
int calib_sets = 0;
float rate = 0.f;
bool grabbing = false;
int inits = 0;
int connects = 0;
// The newest frame: counts, and the camera's number for it.
std::vector<uint16_t> px;
int64_t image_num = 0; // ours; the camera reports 0 for every frame
int lut_calls = 0; // the first LUTs after connecting are empty
uint32_t w = 8, h = 4;
Ret no_frame = kErrNotFound; // what getdata says before any frame
std::atomic<bool> frozen{false}; // stop producing frames (stall)
void push(uint16_t v, uint16_t hot = 0) {
std::lock_guard<std::mutex> l(m);
px.assign(static_cast<size_t>(w) * h, v);
if (hot) px[5] = hot;
++image_num;
}
};
FakeSdk* g = nullptr;
void copyText(const std::string& s, char* buf, int len) {
std::memset(buf, 0, static_cast<size_t>(len));
std::memcpy(buf, s.data(), std::min<size_t>(s.size(), static_cast<size_t>(len) - 1));
}
Ret fInit() { std::lock_guard<std::mutex> l(g->m); ++g->inits; return kSuccess; }
void fUninit() {}
Ret fVersion(char* b, int n) { copyText("4.3.44", b, n); return kSuccess; }
Ret fTypes(char* b, int n) { copyText("Simulator;TARISirmini", b, n); return kSuccess; }
Ret fCreate(Handle* d, int32_t type, char*) { *d = type == 1 ? 42 : 0; return type == 1 ? kSuccess : kErrNotFound; }
Ret fFree(Handle* d) { *d = 0; return kSuccess; }
Ret fSearch(Handle, int32_t* n) {
std::lock_guard<std::mutex> l(g->m);
*n = static_cast<int32_t>(g->search.size());
return kSuccess;
}
Ret fConnect(Handle, char* s) {
std::lock_guard<std::mutex> l(g->m);
const std::string str = s;
g->connect_attempts.push_back(str);
const bool ok = str.size() >= g->accept.size() &&
str.compare(str.size() - g->accept.size(), g->accept.size(), g->accept) == 0;
if (ok) { ++g->connects; g->lut_calls = 0; }
return ok ? kSuccess : kTypeErr | 0x10;
}
Ret fDisconnect(Handle) { return kSuccess; }
Ret fStart(Handle, int32_t) { std::lock_guard<std::mutex> l(g->m); g->grabbing = true; return kSuccess; }
Ret fStop(Handle, int32_t) { std::lock_guard<std::mutex> l(g->m); g->grabbing = false; return kSuccess; }
Ret fGetParam(Handle, uint32_t what, void* data, uint32_t type) {
std::lock_guard<std::mutex> l(g->m);
if (what == param::kSearchString && type == kTypeIdxString) {
auto* p = static_cast<IdxString*>(data);
if (p->index < 0 || p->index >= static_cast<int>(g->search.size())) return kErrNotFound;
copyText(g->search[p->index], p->value.text, p->value.len);
return kSuccess;
}
if (what == param::kCalibActive && type == kTypeIdxInt32) {
static_cast<IdxInt32*>(data)->value = g->cal;
return kSuccess;
}
if (what == param::kCalibCount && type == kTypeInt32) { *static_cast<int32_t*>(data) = 2; return kSuccess; }
if (what == param::kCalibChangeInProgress && type == kTypeInt32) { *static_cast<int32_t*>(data) = 0; return kSuccess; }
if (what == param::kCalibName && type == kTypeIdxString) {
auto* p = static_cast<IdxString*>(data);
copyText(p->index == 0 ? "FF (-40-120C) #4101" : "FF (0-600C) #4102", p->value.text, p->value.len);
return kSuccess;
}
if (what == param::kCameraName && type == kTypeString) {
auto* p = static_cast<String*>(data);
copyText("TARISirmini", p->text, p->len);
return kSuccess;
}
if (what == param::kCameraTempHousing && type == kTypeSingle) { *static_cast<float*>(data) = 308.15f; return kSuccess; }
return kErrNotSupported;
}
Ret fSetParam(Handle, uint32_t what, void* data, uint32_t type) {
std::lock_guard<std::mutex> l(g->m);
if (what == param::kCalibActive && type == kTypeIdxInt32) {
g->cal = static_cast<IdxInt32*>(data)->value;
++g->calib_sets;
return kSuccess;
}
if (what == param::kFramerateHz && type == kTypeSingle) { g->rate = *static_cast<float*>(data); return kSuccess; }
if (what == param::kPollingEnable) return kSuccess;
return kErrNotSupported;
}
// A memory object is a copy of the newest frame.
struct Mem {
std::vector<uint16_t> px;
uint32_t w, h;
int64_t num;
int cal;
};
Ret fGetData(Handle, int32_t what, Handle* mem) {
std::lock_guard<std::mutex> l(g->m);
if (what != kMemIrDigitFrame || !g->grabbing || g->px.empty() || g->frozen) return g->no_frame;
*mem = reinterpret_cast<Handle>(new Mem{g->px, g->w, g->h, g->image_num, g->cal});
return kSuccess;
}
Ret fMemFree(Handle* mem) { delete reinterpret_cast<Mem*>(*mem); *mem = 0; return kSuccess; }
Ret fDim(Handle mem, uint32_t* w, uint32_t* h, uint32_t* t) {
auto* m = reinterpret_cast<Mem*>(mem);
*w = m->w; *h = m->h; *t = 0;
return kSuccess;
}
Ret fPtr(Handle mem, Handle* p, int32_t* bytes) {
auto* m = reinterpret_cast<Mem*>(mem);
*p = reinterpret_cast<Handle>(m->px.data());
*bytes = static_cast<int32_t>(m->px.size() * 2);
return kSuccess;
}
Ret fInfo(Handle mem, void* info) {
// As the TarisIR mini: no frame counter, but each frame has its own time.
auto* mi = static_cast<MemInfo*>(info);
mi->image_num = 0;
mi->timestamp_ms = 200.0 * static_cast<double>(reinterpret_cast<Mem*>(mem)->num);
mi->missed_packets = 1;
return kSuccess;
}
Ret fLut(Handle mem, void* lut, float, float, float, float) {
const double kpc = reinterpret_cast<Mem*>(mem)->cal == 1 ? 0.02 : 0.01;
auto* l = static_cast<float*>(lut);
{
std::lock_guard<std::mutex> lk(g->m);
if (++g->lut_calls <= 2) { // still empty, and "ok", as on the tower
for (int i = 0; i < 65536; ++i) l[i] = 0.f;
return kSuccess;
}
}
for (int i = 0; i < 65536; ++i) l[i] = static_cast<float>(i * kpc);
return kSuccess;
}
std::shared_ptr<const Api> fakeApi() {
auto a = std::make_shared<Api>();
a->dll_init = fInit; a->dll_uninit = fUninit; a->dll_version = fVersion;
a->dll_devicetypenames = fTypes; a->dev_create = fCreate; a->dev_free = fFree;
a->dev_search = fSearch; a->dev_connect = fConnect; a->dev_disconnect = fDisconnect;
a->dev_startgrab = fStart; a->dev_stopgrab = fStop; a->dev_getparam = fGetParam;
a->dev_setparam = fSetParam; a->dev_getdata = fGetData; a->mem_free = fMemFree;
a->mem_getdimension = fDim; a->mem_getdataptr = fPtr; a->mem_getinfo = fInfo;
a->mem_getdvlut = fLut;
return a;
}
TarisCameraSource::Options fastOptions() {
TarisCameraSource::Options o;
o.serial = "2017744";
o.frame_rate_hz = 50; // polls every 10 ms
o.retry_min_ms = 20;
o.retry_max_ms = 40;
o.stall_ms = 2000;
return o;
}
// Each call of pred() counts: trigger() is not idempotent.
template <class F> bool waitFor(F pred, int ms = 3000) {
const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms);
for (;;) {
if (pred()) return true;
if (std::chrono::steady_clock::now() >= end) return false;
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
}
struct Harness {
FakeSdk sdk;
TarisCameraSource cam;
std::mutex m;
std::vector<Frame> frames;
explicit Harness(TarisCameraSource::Options o = fastOptions(), bool net_raw = true)
: cam((g = &sdk, o), fakeApi(), [net_raw] { return net_raw; }) {
cam.setFrameCallback([this](const Frame& f) {
std::lock_guard<std::mutex> l(m);
frames.push_back(f);
});
}
~Harness() { cam.close(); g = nullptr; }
bool online() { return waitFor([this] { return cam.offline(0).empty(); }); }
// Trigger until a frame comes or the time is up.
bool shot(int ms = 2000) { return waitFor([this] { return cam.trigger(); }, ms); }
};
} // namespace
TEST_CASE("the thermal camera connects, trying adapters until one answers") {
Harness t;
t.sdk.accept = "#3"; // the adapter index has moved since the search
t.cam.open();
t.cam.start();
t.sdk.push(29315, 57315);
REQUIRE(t.online());
std::lock_guard<std::mutex> l(t.sdk.m);
REQUIRE(t.sdk.connect_attempts.size() >= 4);
CHECK(t.sdk.connect_attempts.front() == "SN:2017744|00:0e:c6:aa:bb:cc|192.168.0.200#0");
CHECK(t.sdk.connect_attempts.back().substr(t.sdk.connect_attempts.back().size() - 2) == "#3");
CHECK(t.sdk.rate == doctest::Approx(50.f));
CHECK(t.sdk.grabbing);
CHECK(t.sdk.calib_sets == 0); // already on the wanted profile
}
TEST_CASE("a fresh frame is delivered once, as counts with their scale") {
Harness t;
t.cam.open();
t.cam.start();
t.sdk.push(29315, 57315);
REQUIRE(t.shot());
CHECK_FALSE(t.cam.trigger()); // the same frame is not handed out twice
REQUIRE(t.frames.size() == 1);
const Frame& f = t.frames[0];
CHECK(f.layout == PixelLayout::Mono16);
CHECK(f.width == 8);
CHECK(f.height == 4);
REQUIRE(f.data.size() == 64);
uint16_t px5 = 0;
std::memcpy(&px5, f.data.data() + 10, 2);
CHECK(px5 == 57315);
REQUIRE(f.thermal);
CHECK(f.thermal->cal == 0);
CHECK(f.thermal->kelvin_per_count == doctest::Approx(0.01)); // documented, then measured
CHECK(f.thermal->missed_packets == 1);
t.sdk.push(29400);
CHECK(t.shot());
double v = 0;
CHECK(t.cam.readFeature(0, "CalibProfile", v));
CHECK(v == 0);
CHECK(waitFor([&] { return t.cam.readFeature(0, "HousingTemperature", v); }, 7000));
CHECK(v == doctest::Approx(35.0));
}
TEST_CASE("the zero frames after starting are never delivered") {
Harness t;
t.cam.open();
t.cam.start();
t.sdk.push(0);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
CHECK_FALSE(t.cam.trigger());
t.sdk.push(29315);
CHECK(t.shot());
}
TEST_CASE("a frame older than max_frame_age_ms is not kept") {
auto o = fastOptions();
o.max_frame_age_ms = 50;
Harness t(o);
t.cam.open();
t.cam.start();
t.sdk.push(29315);
REQUIRE(t.online());
t.sdk.frozen = true; // no newer frame arrives
std::this_thread::sleep_for(std::chrono::milliseconds(150));
CHECK_FALSE(t.cam.trigger());
}
TEST_CASE("delivery stops while stopped, the camera keeps grabbing") {
Harness t;
t.cam.open();
t.sdk.push(29315);
REQUIRE(t.online());
CHECK_FALSE(t.cam.trigger()); // not started
t.cam.start();
CHECK(t.cam.trigger());
t.cam.stop();
t.sdk.push(29400);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
CHECK_FALSE(t.cam.trigger());
std::lock_guard<std::mutex> l(t.sdk.m);
CHECK(t.sdk.grabbing);
}
TEST_CASE("the configured calibration profile is set on connect") {
auto o = fastOptions();
o.calib_profile = 1;
Harness t(o);
t.cam.open();
t.cam.start();
t.sdk.push(14658);
REQUIRE(t.shot());
CHECK(t.sdk.calib_sets == 1);
REQUIRE(t.frames[0].thermal);
CHECK(t.frames[0].thermal->cal == 1);
CHECK(t.frames[0].thermal->kelvin_per_count == doctest::Approx(0.02));
}
TEST_CASE("switching the profile while running changes the scale of later frames") {
Harness t;
t.cam.open();
t.cam.start();
t.sdk.push(29315);
REQUIRE(t.shot());
REQUIRE(t.cam.setFeature(0, "CalibProfile", "1"));
CHECK_FALSE(t.cam.setFeature(0, "CalibProfile", "7")); // the camera has two
CHECK_FALSE(t.cam.setFeature(0, "CalibProfile", "x"));
t.sdk.push(14658);
REQUIRE(t.shot());
REQUIRE(t.frames.back().thermal);
CHECK(t.frames.back().thermal->cal == 1);
CHECK(t.frames.back().thermal->kelvin_per_count == doctest::Approx(0.02));
}
TEST_CASE("a camera not on the network is offline, says why, and is found later") {
Harness t;
t.sdk.search = {"SN:999|00:00:00:00:00:01|192.168.0.201#0"};
t.cam.open();
t.cam.start();
t.sdk.push(29315);
CHECK(waitFor([&] { return t.cam.offline(0).find("not found") != std::string::npos; }));
CHECK_FALSE(t.cam.trigger());
{
std::lock_guard<std::mutex> l(t.sdk.m);
t.sdk.search = {"SN:2017744|00:0e:c6:aa:bb:cc|192.168.0.200#0"};
}
CHECK(t.online());
CHECK(t.shot());
}
TEST_CASE("without CAP_NET_RAW the camera stays offline and the SDK is not touched") {
Harness t(fastOptions(), false);
t.cam.open();
CHECK(waitFor([&] { return t.cam.offline(0).find("cap_net_raw") != std::string::npos; }));
CHECK(t.sdk.inits == 0);
CHECK_FALSE(t.cam.trigger());
}
TEST_CASE("a camera that goes quiet is reconnected") {
auto o = fastOptions();
o.stall_ms = 100;
Harness t(o);
t.cam.open();
t.cam.start();
t.sdk.push(29315);
REQUIRE(t.online());
t.sdk.frozen = true;
CHECK(waitFor([&] { return t.cam.deviceInfo()[0].restarts >= 1; }));
t.sdk.frozen = false;
t.sdk.push(29400);
CHECK(t.shot());
std::lock_guard<std::mutex> l(t.sdk.m);
CHECK(t.sdk.connects >= 2);
}
TEST_CASE("a missing SDK library leaves the camera offline, not the program down") {
auto o = fastOptions();
o.library = "libdoes_not_exist_irbgrab.so";
TarisCameraSource cam(o, nullptr, [] { return true; });
cam.open();
CHECK(waitFor([&] { return cam.offline(0).find("cannot load") != std::string::npos; }));
CHECK_FALSE(cam.trigger());
cam.close();
}
TEST_CASE("the documented scales") {
CHECK(TarisCameraSource::expectedKelvinPerCount(0) == 0.01);
CHECK(TarisCameraSource::expectedKelvinPerCount(1) == 0.02);
CHECK(TarisCameraSource::expectedKelvinPerCount(2) == 0.0);
}

View File

@ -4,6 +4,7 @@
#include "fgc/HardwareChecks.h" #include "fgc/HardwareChecks.h"
#include <algorithm> #include <algorithm>
#include "fgc/CompositeCameraSource.h"
#include "fgc/mock/MockCameraSource.h" #include "fgc/mock/MockCameraSource.h"
#include "fgc/mock/MockImuSource.h" #include "fgc/mock/MockImuSource.h"
#include "fgc/mock/MockMotorController.h" #include "fgc/mock/MockMotorController.h"
@ -203,6 +204,32 @@ TEST_CASE("a healthy camera passes frames, forced dark and forced bright, and is
CHECK(st.pct_high < 1.0); CHECK(st.pct_high < 1.0);
} }
TEST_CASE("with a thermal camera beside it, both deliver frames and only colour is forced") {
std::vector<std::unique_ptr<ICameraSource>> v;
v.push_back(std::make_unique<MockCameraSource>());
v.push_back(std::make_unique<MockCameraSource>(1, 640, 480, PixelLayout::Mono16));
CompositeCameraSource cam(std::move(v));
MockMotorController motor;
motor.start();
HardwareTestContext hw;
hw.camera = &cam;
hw.configured_cameras = {"rgb", "tir"};
hw.frame_timeout_ms = {200, 200};
TestRunner tr(motor, nullptr, unitGeometry(), T_CAM_ALL, fastProfile(), ".", hw);
auto rep = runToCompletion(tr);
REQUIRE(rep);
// 16-bit counts are two bytes a pixel, and that is a whole frame.
CHECK(section(*rep, "cam_frames")->pass);
// No exposure to force on a thermal camera: noted, not failed.
const TestSection* dark = section(*rep, "cam_dark");
REQUIRE(dark);
CHECK(dark->pass);
CHECK(std::any_of(dark->notes.begin(), dark->notes.end(), [](const std::string& n) {
return n.find("tir: not applicable") != std::string::npos;
}));
CHECK(rep->all_pass);
}
TEST_CASE("a camera that delivers nothing fails the frames leaf") { TEST_CASE("a camera that delivers nothing fails the frames leaf") {
MockCameraSource cam; MockCameraSource cam;
cam.setFault(MockCameraSource::Fault::NoFrames); cam.setFault(MockCameraSource::Fault::NoFrames);

View File

@ -463,6 +463,9 @@ TEST_CASE("Health payload: unknown readings are null, numbers rounded") {
CHECK(j.find("\"throttle_events_last_min\":14") != std::string::npos); CHECK(j.find("\"throttle_events_last_min\":14") != std::string::npos);
CHECK(j.find("\"interval_s\":10.0") != std::string::npos); CHECK(j.find("\"interval_s\":10.0") != std::string::npos);
CHECK(j.find("\"time\":1700000000000") != std::string::npos); CHECK(j.find("\"time\":1700000000000") != std::string::npos);
CHECK(j.find("\"cams_offline\":\"\"") != std::string::npos); // all cameras delivering
h.cams_offline = "TIR";
CHECK(healthJson(h).find("\"cams_offline\":\"TIR\"") != std::string::npos);
CHECK(j.front() == '{'); CHECK(j.front() == '{');
CHECK(j.back() == '}'); CHECK(j.back() == '}');
} }

View File

@ -0,0 +1,92 @@
#include "fgc/ThermalStats.h"
#include <doctest/doctest.h>
#include <vector>
using namespace fgc;
namespace {
// 20.00 degC at the default 0.01 K per count.
constexpr uint16_t kRoom = 29315;
std::vector<uint16_t> flat(uint32_t w, uint32_t h, uint16_t v) {
return std::vector<uint16_t>(static_cast<size_t>(w) * h, v);
}
} // namespace
TEST_CASE("counts convert to tenths of a degree Celsius under either profile") {
CHECK(countsToDeciC(27315, 0.01) == 0); // 273.15 K
CHECK(countsToDeciC(kRoom, 0.01) == 200); // 20.0 degC
CHECK(countsToDeciC(23315, 0.01) == -400); // -40.0 degC, sky
CHECK(countsToDeciC(57315, 0.01) == 3000); // 300.0 degC
// Profile 1 doubles the step: the same temperature is half the count.
CHECK(countsToDeciC(14658, 0.02) == 200); // 293.16 K -> 20.01 rounds to 20.0
CHECK(countsToDeciC(28658, 0.02) == 3000); // 573.16 K
// Rounded to nearest, not truncated: 20.06 degC.
CHECK(countsToDeciC(29321, 0.01) == 201);
CHECK(countsToDeciC(29319, 0.01) == 200); // 20.04 degC
}
TEST_CASE("maximum, median and hot spot of a scene") {
const uint32_t w = 8, h = 4;
auto px = flat(w, h, kRoom);
px[2 * w + 5] = 57315; // 300 degC at (5, 2)
ThermalFrameInfo info;
const auto s = thermalStats(px.data(), w, h, info);
REQUIRE(s.valid);
CHECK(s.valid_pixels == w * h);
CHECK(s.max_count == 57315);
CHECK(s.tmax_decic == 3000);
CHECK(s.median_count == kRoom); // one outlier does not move the median
CHECK(s.tmed_decic == 200);
CHECK(s.hotx == doctest::Approx(5.5 / 8)); // pixel centre, normalised
CHECK(s.hoty == doctest::Approx(2.5 / 4));
}
TEST_CASE("the median is the lower middle value") {
const std::vector<uint16_t> px = {30000, 29000, 31000, 28000}; // sorted 28 29 30 31
const auto s = thermalStats(px.data(), 4, 1, ThermalFrameInfo{});
CHECK(s.median_count == 29000);
}
TEST_CASE("the first occurrence of the maximum is the hot spot") {
auto px = flat(4, 4, kRoom);
px[1 * 4 + 1] = 40000;
px[3 * 4 + 3] = 40000;
const auto s = thermalStats(px.data(), 4, 4, ThermalFrameInfo{});
CHECK(s.hotx == doctest::Approx(1.5 / 4));
CHECK(s.hoty == doctest::Approx(1.5 / 4));
}
TEST_CASE("zero and wrapped counts take no part") {
auto px = flat(4, 2, kRoom);
px[0] = 0; // the camera's start-up frames are zeros
px[1] = 65535; // past the LUT's wrap: not a temperature
px[2] = 65281;
ThermalFrameInfo info;
const auto s = thermalStats(px.data(), 4, 2, info);
REQUIRE(s.valid);
CHECK(s.valid_pixels == 5);
CHECK(s.max_count == kRoom);
// At the limit itself the count still counts.
px[3] = info.valid_count_max;
CHECK(thermalStats(px.data(), 4, 2, info).max_count == info.valid_count_max);
}
TEST_CASE("a frame with no valid pixel has no statistics") {
const auto zeros = flat(4, 4, 0);
CHECK_FALSE(thermalStats(zeros.data(), 4, 4, ThermalFrameInfo{}).valid);
CHECK_FALSE(thermalStats(nullptr, 4, 4, ThermalFrameInfo{}).valid);
CHECK_FALSE(thermalStats(zeros.data(), 0, 4, ThermalFrameInfo{}).valid);
}
TEST_CASE("the scale comes from the frame's profile") {
const auto px = flat(2, 2, 14658); // 20 degC under profile 1
ThermalFrameInfo info;
info.cal = 1;
info.kelvin_per_count = 0.02;
CHECK(thermalStats(px.data(), 2, 2, info).tmed_decic == 200);
}