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/ChannelTopics.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/ThermalGuard.cpp
src/sensors/I2cBus.cpp
@ -108,7 +112,7 @@ target_include_directories(fgc_core PUBLIC
${CMAKE_SOURCE_DIR}/include
${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.
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.
[Camera.rgb]
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
label = RGB ; output subdir + CamEvent "cam"
profile_dir = camera_profiles/rgb
@ -98,6 +98,27 @@ settle_rule = brightness
settle_wait_ms = 2500
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]
; Motor-controller serial device and baud rate.
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
; whenever the sensor cannot be read. Needs env = dht11 and [Pins] fan_pwm.
;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
; 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.
@ -64,6 +65,7 @@ pan_step_deg = 6
; (N5105) needs 2 workers for that. jxl_threads is libjxl's threads per frame;
; 0 or absent = 4 / save_workers. save_queue_max is how many frames may wait
; or be saving before the scan holds at its waypoint (each raw frame ~24 MB).
; A thermal camera adds a frame per picture (0.6 MB): raise it by 2.
save_workers = 1
;jxl_threads = 0
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
; GigE IP address.
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 && \
\"\$CMAKE\" --build build $JOBS_FLAG"
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
# 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) |
| `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) |
| `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 |
| 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 |
| 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
(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
### 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 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) |
| `Serial` | `device` | string | `/dev/ttyACM0` | Motor-controller serial device |
| `Serial` | `baud` | int | `115200` | Serial baud rate |
| `Camera.<name>` | `id` | string | — | USB `DEV_` id or GigE IP. Required when enabled |
| `Camera.<name>` | `driver` | `vimba` | `vimba` | Backend. Only `vimba` is implemented |
| `Camera.<name>` | `id` | string | — | `vimba`: USB `DEV_` id or GigE IP; `taris`: the camera's serial number. Required when enabled |
| `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>` | `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>` | `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 |
@ -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.<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.<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) | `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) |
@ -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
firmware `ST` lines (per-axis state + encoder counts), parsed by
[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
an error.
- 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
thermal or NIR unit needs a new `ICameraSource` implementation behind that key.
- ~~Support a second camera backend.~~ Done for the thermal camera (`driver = taris`,
`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
removed).
- **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 |
|-------|------|---------|
| `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** |
| `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) |
@ -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
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
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).
@ -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,
"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,
"save_ms_mean":null,"save_ms_max":null,"held_s":0,"interval_s":10.0,
"time":1759917600000}
"save_ms_mean":null,"save_ms_max":null,"cams_offline":"","held_s":0,
"interval_s":10.0,"time":1759917600000}
```
| 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) |
| `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 |
| `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 |
| `interval_s` | number | Time between pictures now |
| `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
> 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
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 {
std::string name; // the block suffix, e.g. "rgb"
bool enabled = true;
std::string driver = "vimba"; // only "vimba" is implemented
std::string id; // USB DEV_ id or GigE IP
// "vimba" (Allied Vision, colour) or "taris" (InfraTec TarisIR, thermal).
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 profile_dir; // relative => resolved next to config.ini
std::string profile = "standard"; // which profile to start in
std::string profile_dir; // vimba only; relative => resolved next to config.ini
// 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
// 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".
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
// Timing for `profile`, or the defaults when the block does not name it.
@ -219,7 +235,10 @@ struct CameraSpec {
};
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.
double jxl_distance = 0.8;
int jxl_effort = 4;

View File

@ -3,9 +3,11 @@
#include "fgc/CameraProfile.h"
#include "fgc/RawImage.h"
#include "fgc/SettleRule.h"
#include "fgc/ThermalStats.h"
#include <cstdint>
#include <functional>
#include <optional>
#include <string>
#include <vector>
@ -32,6 +34,8 @@ struct Frame {
// Sampled from `data` on arrival (see RawImage.h); invalid when the layout
// is Unknown.
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
@ -113,6 +117,12 @@ public:
// Default: none. Implementations may refresh a throttled cache, so non-const.
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 ------------------------------------------------
// The camera's configuration otherwise changes through setProfile() and
// nothing else; these let the self-test drive a camera to known extremes and

View File

@ -1,17 +1,32 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
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.
struct CamEvent {
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 pitch_decideg = 0; // pitch elevation * 10 (one decimal as integer)
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
@ -34,6 +49,7 @@ struct HealthReport {
int save_count_last_min = 0;
double save_ms_mean = 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
double interval_s = 0.0; // the time between pictures now
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
// <output_dir>/<label>/<timestamp>.jxl, and publish a CamEvent. Raw frames
// (BayerRG8, BayerRG12p) are de-Bayered first and saved at their own bit depth,
// so a 12-bit frame becomes a 12-bit file. Runs background workers so encoding
// 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
// camera source (real or mock) can feed it.
class ImagePipeline {
@ -89,6 +91,8 @@ public:
// Last CamEvent published (camera label, heading/pitch, timestamp), for the
// UI's camera panel. Thread-safe; nullopt until the first capture is saved.
std::optional<CamEvent> lastEvent() const;
// 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).
long long imagesSaved() const { return saved_.load(); }
@ -117,8 +121,10 @@ private:
void run();
void process(const QueuedFrame& queued);
// Encode and write one image; false (logged) on failure.
bool save(const Frame& frame, const CameraOutput& out, const std::string& file);
// Encode and write one image; false (logged) on failure. For a thermal
// 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
// range (a source delivering more cameras than were configured).
const CameraOutput& outputFor(int cam_id) const;
@ -136,6 +142,7 @@ private:
mutable std::mutex last_event_mutex_;
std::optional<CamEvent> last_event_;
std::optional<CamEvent> last_thermal_event_;
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.

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
// (GenICam "12p": p0 = b0 | (b1 & 0x0F) << 8,
// 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'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".
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.
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);
// 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);
@ -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
// pixel, so all colour sites of a mosaic are visited. Cheap enough to run on
// every frame the camera delivers (a few thousand samples).
// mean on a 0..255 scale whatever the bit depth (12-bit values / 16)
// 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)
struct BrightnessSample {
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 <chrono>
#include <cstring>
#include <map>
#include <string>
#include <thread>
@ -13,8 +14,10 @@ namespace fgc {
// Synthetic camera for development without hardware. On trigger() it generates
// a small gradient frame (varying with time so successive captures differ) and
// delivers it to the frame callback - as RGB8 by default, or as the raw mosaic
// (BayerRG8, BayerRG12p) a raw-capture profile would deliver. setProfile() only
// delivers it to the frame callback - as RGB8 by default, as the raw mosaic
// (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
// to exercise profile switching end to end with --mock-camera.
class MockCameraSource : public ICameraSource {
@ -54,6 +57,11 @@ public:
}
bool readFeature(int camera_index, const std::string& name, double& value) override {
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 == "IntensityControllerTolerance") { value = 5.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,
double& max) override {
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 == "Gain") { min = 0.0; max = 48.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
// captures differ.
Frame synth() const {
if (layout_ == PixelLayout::Mono16) return thermal();
Frame f;
f.width = width_;
f.height = height_;
@ -182,6 +192,31 @@ private:
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
// of its filter (BayerRG: R G / G B), 8-bit or packed 12-bit.
Frame mosaic(const Frame& rgb) const {

View File

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

View File

@ -144,6 +144,7 @@ struct CameraDeviceView {
unsigned width = 0, height = 0; long long payload_bytes = 0;
double exposure_us = 0, gain_db = 0, temperature_c = 0, actual_fps = 0;
long long frames_delivered = 0, frames_dropped = 0, restarts = 0;
std::string offline; // why it delivers nothing; empty while it does
};
struct CaptureView {
@ -172,6 +173,15 @@ struct CaptureView {
double last_heading_deg = 0.0;
double last_pitch_deg = 0.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 {

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
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
hr platformio
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 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)
.count());
@ -187,10 +188,25 @@ void ImagePipeline::process(const QueuedFrame& queued) {
ev.heading_decideg = static_cast<int>(o.yaw_deg * 10);
ev.pitch_decideg = static_cast<int>(o.pitch_deg * 10);
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);
{
std::lock_guard<std::mutex> lock(last_event_mutex_);
last_event_ = ev;
if (ev.thermal) last_thermal_event_ = ev;
}
saved_.fetch_add(1);
@ -198,15 +214,24 @@ void ImagePipeline::process(const QueuedFrame& queued) {
<< (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;
bool rotate = rotateCode(out.rotation_deg, code);
const int h = static_cast<int>(frame.height), w = static_cast<int>(frame.width);
auto* data = const_cast<uint8_t*>(frame.data.data());
cv::Mat img; // what gets encoded: 8-bit (CV_8U) or 12-bit values in CV_16U
cv::Mat img; // what gets encoded: 8-bit (CV_8U), or 12- or 16-bit values in CV_16U
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;
if (frame.layout == PixelLayout::BayerRG8) {
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();
// 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,
static_cast<float>(out.jxl_distance), out.jxl_effort,
params_.jxl_threads)) {
distance, out.jxl_effort, params_.jxl_threads)) {
LOG_ERROR << "Failed to encode " << file;
return false;
}
@ -245,4 +275,9 @@ std::optional<CamEvent> ImagePipeline::lastEvent() const {
return last_event_;
}
std::optional<CamEvent> ImagePipeline::lastThermalEvent() const {
std::lock_guard<std::mutex> lock(last_event_mutex_);
return last_thermal_event_;
}
} // 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 <thread>
#include "fgc/CompositeCameraSource.h"
#include "fgc/TarisCameraSource.h"
#if FGC_WITH_MQTT
#include "fgc/MqttControlChannel.h"
#endif
@ -420,6 +423,9 @@ struct Application::Impl {
#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() {
bool mock = !realCameraWanted();
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";
}
#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) {
const int n = active.empty() ? 1 : static_cast<int>(active.size());
if (opts.mock_camera_full.value_or(cfg.features.mock_camera_full)) {
@ -453,6 +479,19 @@ struct Application::Impl {
#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
// 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
@ -464,6 +503,14 @@ struct Application::Impl {
for (const auto& c : cfg.camera.cameras) {
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;
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
// single configured camera. Returns false (and warns) if unknown.
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);
if (ci == camera_profiles.end()) {
std::string known;
@ -551,6 +618,15 @@ struct Application::Impl {
rule = rule || t.settle_rule == "brightness";
}
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;
if (rule && camera) {
double v = 0;
@ -1037,8 +1113,10 @@ struct Application::Impl {
// Live per-camera identity/telemetry/stats.
s.capture.devices.clear();
if (camera) {
int index = 0;
for (const auto& d : camera->deviceInfo()) {
CameraDeviceView v;
v.offline = camera->offline(index++);
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.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_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 ---
@ -1337,6 +1423,14 @@ struct Application::Impl {
h.save_ms_mean = t.mean_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.interval_s = scheduler ? scheduler->intervalMs() / 1000.0 : 0.0;
h.timestamp_ms = nowEpochMs();
@ -2077,12 +2171,77 @@ struct Application::Impl {
// 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.
// 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();
for (int i = 0; i < camera->cameraCount(); ++i)
LOG_INFO << "camera " << i << (camera->resetCamera(i) ? ": reset, back on its profile"
for (int i = 0; i < camera->cameraCount(); ++i) {
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)");
}
}
// `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
// 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") {
running = false;
} else if (c.verb == "camera") {
if (c.device == "reset") resetCameras();
else LOG_WARN << "usage: camera reset";
if (c.device == "reset") resetCameras(c.option);
else if (c.device == "calib") handleCalib(c);
else LOG_WARN << "usage: camera reset [<camera>] | camera calib [<profile>]";
} else if (c.verb == "snapshot") {
takeSnapshot();
} else if (c.verb == "capture") {

View File

@ -1,6 +1,7 @@
#include "fgc/ChannelTopics.h"
#include <cctype>
#include <cmath>
#include <cstdio>
namespace fgc {
@ -9,23 +10,47 @@ std::string towerTopic(const std::string& tower, const std::string& 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 {
// 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) {
char b[32];
std::snprintf(b, sizeof(b), "%.*f", decimals, v);
return b;
if (!std::isfinite(v)) return "null";
long long scale = 1;
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
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) {
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) +
@ -40,6 +65,7 @@ std::string healthJson(const HealthReport& h) {
",\"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_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) +
",\"interval_s\":" + num(h.interval_s, 1) +
",\"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 = get(kv, pre + "profile", c.profile);
c.rotation_deg = getInt(kv, pre + "rotation_deg", c.rotation_deg);
c.jxl_distance = getDouble(kv, pre + "jxl_distance", cfg.camera.jxl_distance);
// 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.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
for (const auto& [key, val] : kv) {
@ -407,13 +415,28 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
"'fixed' or 'brightness'");
}
if (c.driver != "vimba")
throw std::runtime_error("Camera." + name + ".driver must be 'vimba' (got '" +
c.driver + "'); no other backend is implemented yet");
if (c.driver != "vimba" && c.driver != "taris")
throw std::runtime_error("Camera." + name + ".driver must be 'vimba' or 'taris' (got '" +
c.driver + "')");
if (c.enabled && c.id.empty())
throw std::runtime_error("Camera." + name + ".id is required (USB DEV_ id or GigE IP)");
if (c.profile_dir.empty())
throw std::runtime_error("Camera." + name + ".id is required (" +
(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");
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)
throw std::runtime_error("Camera." + name + ".jxl_distance must be >= 0 (0 = lossless)");
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));
}
// 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");
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.
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));
if (pd.is_relative()) pd = std::filesystem::path(path).parent_path() / pd;
c.profile_dir = pd.lexically_normal().string();

View File

@ -49,10 +49,17 @@ const std::vector<HelpSection>& helpCatalog() {
"setup or test."}},
{"set camera profile [<camera>] <name>", "Switch a camera's VimbaX settings profile.", {
"<name> is an XML stem in that camera's profile_dir. The camera may be",
"omitted when only one is configured.",
"omitted when only one colour camera is configured. For the thermal",
"camera it only picks the timing block.",
"Example: set camera profile night"}},
{"camera reset", "Reboot the cameras and restore their profiles.", {
"For a stream that has stopped and will not restart. Takes seconds."}},
{"camera reset [<camera>]", "Reboot the cameras and restore their profiles.", {
"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).", {
{"gimbal move <yaw>[,<pitch>]", "Point at a bearing/elevation in degrees.", {

View File

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

View File

@ -1298,8 +1298,12 @@ void TestRunner::testCamera(TestReport& r) {
f.channels != run.channels) {
run.consistent = false;
}
if (f.data.size() != static_cast<size_t>(f.width) * f.height * f.channels)
run.consistent = false;
// By layout where it is known: packed 12-bit and 16-bit counts are
// 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;
}
const double secs = std::chrono::duration<double>(
@ -1316,6 +1320,17 @@ void TestRunner::testCamera(TestReport& r) {
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
// path out of these phases, and a failure to put it back is a failure of
// 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))}));
}
// "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) {
std::string labels;
for (size_t i = 0; i < c.labels.size(); ++i)
@ -417,6 +426,12 @@ Element cameraPanel(const CaptureView& c) {
} else {
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)));
}
@ -442,12 +457,15 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
for (size_t i = 0; i < c.devices.size(); ++i) {
const CameraDeviceView& d = c.devices[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));
rows.push_back(hbox({text(label + " ") | bold,
text(d.model.empty() ? "?" : d.model) | color(Color::Cyan),
text(" " + d.id) | dim, filler(), st}));
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) +
" " + num(d.payload_bytes / 1048576.0, 1) + " MB")));
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 ---
const CameraConfigView& cf = c.config;
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),
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);
for (const auto& v : cf.cameras) {
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) +
" 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))));
rows.push_back(row(" rotation", text(v.rotation_deg == 0
? std::string("none")
@ -503,6 +534,7 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
formatDegrees(c.last_pitch_deg) + " " +
formatTimeAgo(now, c.last_ts_ms))));
}
if (c.has_thermal) rows.push_back(row("last thermal", thermalLine(c)));
// --- Scan grid ---
rows.push_back(separator());

View File

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

View File

@ -1,5 +1,7 @@
#include <doctest/doctest.h>
#include <clocale>
#include "fgc/ChannelHistory.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);
// The quality gate is gone; nothing should still be publishing a verdict.
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({})));
// 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"}})));
// An enabled camera with no id cannot be opened.
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"}})));
}
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") {
const std::string in =
"[General]\n"

View File

@ -5,6 +5,7 @@
#include <doctest/doctest.h>
#include <chrono>
#include <cstring>
#include <filesystem>
#include <mutex>
#include <thread>
@ -65,6 +66,24 @@ Frame bayerFrame(PixelLayout layout, uint32_t w, uint32_t h, int r, int g, int b
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) {
const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms);
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));
}
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") {
TempDir t;
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") {
TempDir t;
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") {
for (PixelLayout l : {PixelLayout::Mono8, PixelLayout::RGB8, PixelLayout::BayerRG8,
PixelLayout::BayerRG12p})
PixelLayout::BayerRG12p, PixelLayout::Mono16})
CHECK(layoutFromName(layoutName(l)) == l);
CHECK(layoutFromName("BayerRG12") == PixelLayout::Unknown); // unpacked: not handled
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::RGB8, 5328, 3040) == 48591360);
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") {

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 <algorithm>
#include "fgc/CompositeCameraSource.h"
#include "fgc/mock/MockCameraSource.h"
#include "fgc/mock/MockImuSource.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);
}
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") {
MockCameraSource cam;
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("\"interval_s\":10.0") != 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.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);
}