Compare commits

..

No commits in common. "ed72f97fe5ce37188e2b1ae8e8c8ee3b8326e94a" and "a03da2d8f454c8f7a84a434e162d59f4db356494" have entirely different histories.

48 changed files with 75 additions and 3728 deletions

View File

@ -94,10 +94,6 @@ 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
@ -112,7 +108,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 ${CMAKE_DL_LIBS})
target_link_libraries(fgc_core PUBLIC Threads::Threads)
# PUBLIC so the application and the tests both see the same build identity.
target_compile_definitions(fgc_core PUBLIC FGC_GIT_REV="${FGC_GIT_REV}")

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 ; vimba (colour) or taris (thermal, below)
driver = vimba ; only vimba is implemented
id = DEV_0000000000 ; USB DEV_ id, or a GigE IP
label = RGB ; output subdir + CamEvent "cam"
profile_dir = camera_profiles/rgb
@ -98,29 +98,6 @@ 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. Each file and CamEvent has the time its own frame arrived; the
; two of one waypoint share hdg/pit and are at most max_frame_age_ms apart.
; 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
;nuc_interval_s = 30 ; NUC at the start of a move, not mid-picture; 0 = camera's timer
;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,8 +53,7 @@ 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 ("rgb, tir" with
; the thermal camera).
; The [Camera.<name>] blocks expected to be present and found.
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.
@ -65,7 +64,6 @@ 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
@ -249,11 +247,3 @@ 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,10 +308,6 @@ 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), `TarisCameraSource` (InfraTec TarisIR, thermal), both behind `CompositeCameraSource` | `MockCameraSource` (synthetic frames, also thermal counts) |
| `ICameraSource` | `VimbaCameraSource` (Vimba X) | `MockCameraSource` (synthetic frames) |
| `IImuSource` | `MtiImuSource` (Xsens MTi, Boost.Asio) | `MockImuSource` (synthetic orientation) |
| `IEnvSensor` | `Sht41EnvSensor` (SHT41 over I2C) | `MockEnvSensor` (synthetic temp/humidity) |
@ -54,7 +54,6 @@ 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
@ -133,24 +132,6 @@ 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. Each frame keeps the time it
reached the host, so the two of one waypoint differ by up to that age. 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 | — | `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>` | `id` | string | — | USB `DEV_` id or GigE IP. Required when enabled |
| `Camera.<name>` | `driver` | `vimba` | `vimba` | Backend. Only `vimba` is implemented |
| `Camera.<name>` | `label` | string | the block name | Output subdirectory and `CamEvent` `cam` field |
| `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_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` | 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,12 +75,6 @@ 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`) | `nuc_interval_s` | int 0..3600 | `30` | Seconds between NUCs, run by the program at the start of a move so none blanks a picture (after twice this with no move, one runs anyway). `0` leaves the camera's own timer, which now and then takes a picture's frame |
| `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) |
@ -1060,37 +1054,3 @@ 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. Each file and CamEvent has the
time its own frame arrived: the two of one waypoint share `hdg`/`pit` and are at
most `max_frame_age_ms` apart (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.
- **NUC while the gimbal moves.** The camera recalibrates against its shutter
every 30 s and sends no frame for about 0.7 s meanwhile; a picture falling
into that had no thermal image (0.8 % of them on tower 111). The program
switches the camera's timer off and runs the NUC itself at the start of a
move once `nuc_interval_s` has passed; the move and the settle wait leave
seconds before the next picture. A picture that still finds the camera
between frames waits up to 1 s for the next one. On exit the camera's timer
is put back.
- **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,25 +166,8 @@ 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.~~ 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). The 0.8 % of pictures without a thermal frame in the first long run
were the camera's own NUC every 30 s (a 0.7 s pause in its stream); since 2026-10-15 the program runs the
NUC at the start of a move instead. A NIR unit would be a third driver
behind the same key.
- The InfraTec SDK keeps the CPU awake: about 15 of its threads sleep 1 ms at a time, some 15 000 wake-ups
a second, so the cores never settle and the CPU clocks them at turbo (2.8 GHz on the N5105) whenever they
run. Until 2026-10-15 the camera also sent 50 fps (31 MB/s) because the frame rate was not taken; at the
intended 5 fps the system load fell from about 29 % to 18 %, but the wake-ups remain. 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.
- 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.
- 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 (`[Camera.<name>] label`): `RGB`, `TIR`, `ACR` or `NIR` |
| `cam` | string | Camera label: `RGB`, `ACR`, or `NIR` (by camera index) |
| `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,43 +135,6 @@ 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 |
- **Its own `time`.** The thermal camera is asked right after the colour one, at
the same waypoint, and hands over the newest frame it has. `time` (and the file
name) is when that frame reached the tower computer, so it is up to
`max_frame_age_ms` (400) off the colour picture's, earlier or later. The `RGB`
and `TIR` event of one waypoint share `hdg` and `pit` (sampled together) and
follow each other in the stream; pair them by that, not by an equal `time`. 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).
@ -186,8 +149,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,"cams_offline":"","held_s":0,
"interval_s":10.0,"time":1759917600000}
"save_ms_mean":null,"save_ms_max":null,"held_s":0,"interval_s":10.0,
"time":1759917600000}
```
| Field | Type | Meaning |
@ -201,8 +164,7 @@ 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 (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 |
| `saved_last_min`, `save_ms_mean`, `save_ms_max` | int, number/null | Pictures saved in the last minute and how long each took |
| `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,89 +884,6 @@ 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.
- Frame rate (2026-10-15): from power-up the camera sends 50 fps, 31 MB/s and
22 000 packets a second. `Framerate_Hz` set right after connecting answers ok
and changes nothing; set while grabbing it only thins out what the SDK hands
over; set while the grab is stopped, after the stream has run, it holds on the
wire. The program does that once the first frame is in and checks the packet
rate of `[Camera.tir] interface` (5 Hz: about 2 200 packets a second); the log
says `5.3 fps on enp1s0 (2224 packets/s), set on attempt 2`.
- NUC (2026-10-15): the camera runs one every 30 s (`NUC_Interval`) and sends no
frame for 650-760 ms meanwhile. `NUC_Interval = 0` stops that; `NUC_Perform`
(1-point) runs one and returns when it is done, after about 0.67 s.
`NUC_INPROGRESS` stays 0 throughout, also for those. The program now runs the
NUC itself at the start of a move (`nuc_interval_s`).
- 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

@ -1,79 +0,0 @@
#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 keeps its own time, when it reached the host: the thermal frame
// is the newest the camera had, so its time is up to max_frame_age_ms off the
// colour one, and that is what the file name and the CamEvent say.
//
// A follower that is briefly without a fresh frame (frameReady() false: the
// thermal camera's NUC) holds the picture back for up to `follower_wait_ms`,
// so the pair stays whole; past that the leader goes on alone.
//
// 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 = {}, int follower_wait_ms = 1000);
void open() override;
void close() override;
void start() override;
void stop() override;
bool trigger() override;
void moving() override;
bool frameReady() 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_;
int follower_wait_ms_;
long long waiting_since_ = 0; // steady ms a picture began waiting for a follower
};
} // namespace fgc

View File

@ -199,14 +199,11 @@ struct CameraProfileTiming {
struct CameraSpec {
std::string name; // the block suffix, e.g. "rgb"
bool enabled = true;
// "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 driver = "vimba"; // only "vimba" is implemented
std::string id; // USB DEV_ id or GigE IP
std::string label; // output subdir + CamEvent "cam"; defaults to name
std::string profile_dir; // 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";
std::string profile_dir; // relative => resolved next to config.ini
std::string profile = "standard"; // which profile to start in
int rotation_deg = 0; // clockwise; 0/90/180/270
// Encoding, defaulted from [Camera] and overridable per camera.
@ -215,23 +212,6 @@ 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
// Seconds between NUCs the program runs itself, each at the start of a move
// so it never blanks a picture; 0 leaves the camera's own timer, which fires
// mid-picture now and then (TarisCameraSource.h).
int nuc_interval_s = 30;
// 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.
@ -239,10 +219,7 @@ struct CameraSpec {
};
struct CameraConfig {
// 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;
std::vector<CameraSpec> cameras; // in config order; empty is a config error
// [Camera] defaults, inherited by each block unless it overrides them.
double jxl_distance = 0.8;
int jxl_effort = 4;

View File

@ -3,11 +3,9 @@
#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>
@ -34,8 +32,6 @@ 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
@ -86,16 +82,6 @@ public:
// yet, and the caller simply retries.
virtual bool trigger() = 0;
// The gimbal has started moving: nothing will be captured until it stands
// still again and has settled. A camera with housekeeping that blanks its
// stream - the thermal camera's NUC - does it now. Default: nothing.
virtual void moving() {}
// Would trigger() hand over a fresh frame right now? False while a camera is
// briefly between frames (a NUC), so a picture can wait a moment for it
// rather than go without. Default: always.
virtual bool frameReady() { return true; }
// Apply a profile to one camera, identified by its index in the order the
// source opened them (the same index Frame::cam_id carries). The backend
// loads the profile's settings XML through its SDK - see CameraProfile.h.
@ -127,12 +113,6 @@ 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,32 +1,17 @@
#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" / "TIR" / "ACR" / "NIR"
std::string camera; // "RGB" / "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
@ -49,7 +34,6 @@ 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,9 +26,7 @@ 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. 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
// so a 12-bit frame becomes a 12-bit file. Runs background workers so encoding
// never blocks acquisition. Split out of the old VimbaHandler::SaveImage so any
// camera source (real or mock) can feed it.
class ImagePipeline {
@ -91,8 +89,6 @@ 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(); }
@ -121,10 +117,8 @@ private:
void run();
void process(const QueuedFrame& queued);
// 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);
// Encode and write one image; false (logged) on failure.
bool save(const Frame& frame, const CameraOutput& out, const std::string& file);
// Output settings for a camera index, or sane defaults if the index is out of
// range (a source delivering more cameras than were configured).
const CameraOutput& outputFor(int cam_id) const;
@ -142,7 +136,6 @@ 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.

View File

@ -1,148 +0,0 @@
#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;
// Non-uniformity correction, done in the camera with its own shutter.
inline constexpr uint32_t kNucActive = 410; // R bool: correction applied
inline constexpr uint32_t kNucPerform = 411; // W Int2 {mode, flags}: run one now
inline constexpr uint32_t kNucInProgress = 412; // R bool
inline constexpr uint32_t kNucInterval = 415; // R/W s between automatic ones, 0 = off
} // namespace param
// kNucPerform's mode (Int2::value1); value2 takes flags, 0 for none.
inline constexpr int32_t kNucMode1Point = 2;
#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;
};
struct Int2 {
int32_t value1;
int32_t value2;
};
// 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 kExArray2 = 0x02020000; // a pair
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);
inline constexpr uint32_t kType2Int32 = kExArray2 | kBaseInt32 | sizeof(Int2);
// 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,12 +17,10 @@ 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, Mono16 };
enum class PixelLayout { Unknown, Mono8, RGB8, BayerRG8, BayerRG12p };
// The GenICam name ("BayerRG12p"), or "Unknown".
const char* layoutName(PixelLayout layout);
@ -31,7 +29,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, 12 for BayerRG12p, 16 for Mono16. 0 for Unknown.
// Bits per value as delivered: 8, or 12 for BayerRG12p. 0 for Unknown.
int layoutBits(PixelLayout layout);
// Bytes one frame of width x height takes in this layout (0 for Unknown).
size_t layoutFrameBytes(PixelLayout layout, uint32_t width, uint32_t height);
@ -44,8 +42,7 @@ 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,
// 16-bit values / 256)
// mean on a 0..255 scale whatever the bit depth (12-bit values / 16)
// clipped share of samples at or near white (>= 250 on that scale)
struct BrightnessSample {
double mean = -1.0;

View File

@ -1,92 +0,0 @@
#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.
//
// NUC: the camera recalibrates itself against its shutter every 30 s and sends
// no frame for about 0.7 s while it does (measured on tower 111). A picture
// that falls into that gets no thermal frame. So the program switches the
// camera's timer off and runs the NUC itself, at the start of a move - the
// move and the settle wait leave seconds before the next picture - once
// nuc_interval has passed, or after twice that without any move (manual mode).
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
int nuc_interval_ms = 30000;// 0: leave the camera's own NUC timer
};
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;
// Runs a due NUC now (on the worker), while the gimbal moves.
void moving() override;
bool frameReady() 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

@ -1,45 +0,0 @@
#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,7 +5,6 @@
#include <algorithm>
#include <chrono>
#include <cstring>
#include <map>
#include <string>
#include <thread>
@ -14,10 +13,8 @@ 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, 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
// delivers it to the frame callback - as RGB8 by default, or as the raw mosaic
// (BayerRG8, BayerRG12p) a raw-capture profile would deliver. setProfile() only
// records the profile name - there is no sensor to configure - which is enough
// to exercise profile switching end to end with --mock-camera.
class MockCameraSource : public ICameraSource {
@ -57,11 +54,6 @@ 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; }
@ -103,7 +95,6 @@ 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; }
@ -146,7 +137,6 @@ 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_;
@ -192,31 +182,6 @@ 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,9 +33,10 @@ public:
drop(kTopicStatusCode, payload);
}
void publishCamEvent(const CamEvent& e) override {
const std::string payload = camEventJson(e);
LOG_TRACE_CAT(LogCat::Mqtt) << "PUB CamEvent " << payload << " (null channel)";
drop(kTopicCamEvent, payload);
LOG_TRACE_CAT(LogCat::Mqtt) << "PUB CamEvent cam=" << e.camera
<< " hdg=" << e.heading_decideg << " t=" << e.timestamp_ms
<< " (null channel)";
drop(kTopicCamEvent, camEventJson(e));
}
void publishHealth(const HealthReport& h) override {
++health_published_;

View File

@ -144,7 +144,6 @@ 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 {
@ -173,15 +172,6 @@ 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 {

View File

@ -1,82 +0,0 @@
#!/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,15 +1064,6 @@ 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

@ -1,189 +0,0 @@
#include "fgc/CompositeCameraSource.h"
#include "fgc/Logger.h"
#include <chrono>
#include <stdexcept>
namespace fgc {
namespace {
long long steadyMs() {
using namespace std::chrono;
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
}
} // namespace
CompositeCameraSource::CompositeCameraSource(std::vector<std::unique_ptr<ICameraSource>> children,
std::vector<int> counts, int follower_wait_ms)
: children_(std::move(children)), counts_(std::move(counts)),
follower_wait_ms_(follower_wait_ms) {
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() {
bool followers_ready = true;
for (size_t k = 1; k < children_.size(); ++k) followers_ready &= children_[k]->frameReady();
if (!followers_ready) {
const long long now = steadyMs();
if (waiting_since_ == 0) waiting_since_ = now;
if (now - waiting_since_ < follower_wait_ms_) return false; // asked again next tick
LOG_DEBUG << "camera: a follower had no fresh frame for " << now - waiting_since_
<< " ms; taking the picture without it";
}
waiting_since_ = 0;
const bool taken = children_[0]->trigger();
if (taken)
for (size_t k = 1; k < children_.size(); ++k) children_[k]->trigger();
return taken;
}
void CompositeCameraSource::moving() {
for (auto& c : children_) c->moving();
}
bool CompositeCameraSource::frameReady() {
for (auto& c : children_)
if (!c->frameReady()) return false;
return true;
}
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 (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;
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,8 +174,7 @@ 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();
ThermalStats stats;
if (!save(frame, out, file.string(), &stats)) return;
if (!save(frame, out, file.string())) return;
noteSaveTime(std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0)
.count());
@ -188,25 +187,10 @@ 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);
@ -214,24 +198,15 @@ void ImagePipeline::process(const QueuedFrame& queued) {
<< (known ? layoutName(frame.layout) : "legacy") << ')';
}
bool ImagePipeline::save(const Frame& frame, const CameraOutput& out, const std::string& file,
ThermalStats* stats) {
bool ImagePipeline::save(const Frame& frame, const CameraOutput& out, const std::string& file) {
int code = 0;
bool rotate = rotateCode(out.rotation_deg, code);
const int h = static_cast<int>(frame.height), w = static_cast<int>(frame.width);
auto* data = const_cast<uint8_t*>(frame.data.data());
cv::Mat img; // what gets encoded: 8-bit (CV_8U), or 12- or 16-bit values in CV_16U
cv::Mat img; // what gets encoded: 8-bit (CV_8U) or 12-bit values in CV_16U
int bits = 8;
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) {
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);
@ -256,14 +231,9 @@ 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,
distance, out.jxl_effort, params_.jxl_threads)) {
static_cast<float>(out.jxl_distance), out.jxl_effort,
params_.jxl_threads)) {
LOG_ERROR << "Failed to encode " << file;
return false;
}
@ -275,9 +245,4 @@ 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

View File

@ -1,61 +0,0 @@
#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

File diff suppressed because it is too large Load Diff

View File

@ -63,9 +63,6 @@
#include <string>
#include <thread>
#include "fgc/CompositeCameraSource.h"
#include "fgc/TarisCameraSource.h"
#if FGC_WITH_MQTT
#include "fgc/MqttControlChannel.h"
#endif
@ -423,9 +420,6 @@ 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) {
@ -437,27 +431,7 @@ struct Application::Impl {
LOG_WARN << "Camera requested but binary built without Vimba X; using mock camera";
}
#endif
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) {
const auto active = cfg.camera.active();
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)) {
@ -479,19 +453,6 @@ 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
@ -503,14 +464,6 @@ 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);
@ -547,26 +500,6 @@ 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;
@ -618,15 +551,6 @@ 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;
@ -1113,10 +1037,8 @@ 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;
@ -1145,14 +1067,6 @@ 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 ---
@ -1423,14 +1337,6 @@ 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();
@ -2171,76 +2077,11 @@ 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.
// 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;
}
}
void resetCameras() {
camera->stop();
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)";
for (int i = 0; i < camera->cameraCount(); ++i)
LOG_INFO << "camera " << i << (camera->resetCamera(i) ? ": reset, back on its profile"
: ": reset FAILED (see above)");
}
// The one way the mode changes - `mode ...`, the a / p / m keys, [Startup] mode, and a
@ -3319,9 +3160,8 @@ struct Application::Impl {
} else if (c.verb == "exit") {
running = false;
} else if (c.verb == "camera") {
if (c.device == "reset") resetCameras(c.option);
else if (c.device == "calib") handleCalib(c);
else LOG_WARN << "usage: camera reset [<camera>] | camera calib [<profile>]";
if (c.device == "reset") resetCameras();
else LOG_WARN << "usage: camera reset";
} else if (c.verb == "snapshot") {
takeSnapshot();
} else if (c.verb == "capture") {

View File

@ -110,8 +110,6 @@ void CaptureScheduler::sendMove() {
// form ("ERR axis not present") and moves nothing, so address yaw alone.
motor_.sendCommand(moveCommand(yaw_target_, pitch_present_ ? std::optional<long>(pitch_target_)
: std::nullopt));
// The time the move and the settle wait take is free for the cameras.
camera_.moving();
}
bool CaptureScheduler::settledAt(const MotorTelemetry& t) const {

View File

@ -1,7 +1,6 @@
#include "fgc/ChannelTopics.h"
#include <cctype>
#include <cmath>
#include <cstdio>
namespace fgc {
@ -10,47 +9,23 @@ 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) {
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;
char b[32];
std::snprintf(b, sizeof(b), "%.*f", decimals, v);
return b;
}
} // 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) +
@ -65,7 +40,6 @@ 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,18 +383,9 @@ 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);
// 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_distance = getDouble(kv, pre + "jxl_distance", cfg.camera.jxl_distance);
c.jxl_effort = getInt(kv, pre + "jxl_effort", cfg.camera.jxl_effort);
c.demosaic = lowered(get(kv, pre + "demosaic", cfg.camera.demosaic));
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.nuc_interval_s = getInt(kv, pre + "nuc_interval_s", c.nuc_interval_s);
c.interface = get(kv, pre + "interface");
// Per-profile timing: Camera.<name>.<profile>.settle_wait_ms
for (const auto& [key, val] : kv) {
@ -416,31 +407,13 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
"'fixed' or 'brightness'");
}
if (c.driver != "vimba" && c.driver != "taris")
throw std::runtime_error("Camera." + name + ".driver must be 'vimba' or 'taris' (got '" +
c.driver + "')");
if (c.driver != "vimba")
throw std::runtime_error("Camera." + name + ".driver must be 'vimba' (got '" +
c.driver + "'); no other backend is implemented yet");
if (c.enabled && c.id.empty())
throw std::runtime_error("Camera." + name + ".id is required (" +
(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 + ".id is required (USB DEV_ id or GigE IP)");
if (c.profile_dir.empty())
throw std::runtime_error("Camera." + name + ".profile_dir is required");
if (c.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.nuc_interval_s < 0 || c.nuc_interval_s > 3600)
throw std::runtime_error("Camera." + name + ".nuc_interval_s must be in 0..3600 "
"(0 = the camera's own timer)");
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)
@ -458,27 +431,6 @@ 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);
@ -818,7 +770,6 @@ 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,17 +49,10 @@ 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 colour camera is configured. For the thermal",
"camera it only picks the timing block.",
"omitted when only one is configured.",
"Example: set camera profile night"}},
{"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)."}},
{"camera reset", "Reboot the cameras and restore their profiles.", {
"For a stream that has stopped and will not restart. Takes seconds."}},
}},
{"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,7 +1,5 @@
#include "fgc/RawImage.h"
#include <cstring>
namespace fgc {
const char* layoutName(PixelLayout layout) {
@ -10,7 +8,6 @@ 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";
@ -18,7 +15,7 @@ const char* layoutName(PixelLayout layout) {
PixelLayout layoutFromName(const std::string& name) {
for (PixelLayout l : {PixelLayout::Mono8, PixelLayout::RGB8, PixelLayout::BayerRG8,
PixelLayout::BayerRG12p, PixelLayout::Mono16})
PixelLayout::BayerRG12p})
if (name == layoutName(l)) return l;
return PixelLayout::Unknown;
}
@ -33,7 +30,6 @@ 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;
@ -46,7 +42,6 @@ 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;
@ -54,12 +49,6 @@ 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))
@ -90,9 +79,7 @@ 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
: layout == PixelLayout::Mono16 ? px16(frame, i) / 256.0
: frame[i];
double v = layout == PixelLayout::BayerRG12p ? px12p(frame, i) / 16.0 : frame[i];
sum += v;
hi += v >= 250.0;
++s.samples;

View File

@ -1298,12 +1298,8 @@ void TestRunner::testCamera(TestReport& r) {
f.channels != run.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;
if (f.data.size() != static_cast<size_t>(f.width) * f.height * f.channels)
run.consistent = false;
++run.got;
}
const double secs = std::chrono::duration<double>(
@ -1320,17 +1316,6 @@ 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.

View File

@ -1,51 +0,0 @@
#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,15 +379,6 @@ 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)
@ -426,12 +417,6 @@ 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)));
}
@ -457,15 +442,12 @@ 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.offline.empty() ? (text("offline") | color(Color::Red))
: d.streaming ? (text("streaming") | color(Color::Green))
: (text("stopped") | color(Color::Yellow));
Element st = 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 " +
@ -483,17 +465,8 @@ 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)" : " " + backends) | dim}));
}
rows.push_back(hbox({text("CAMERAS") | bold | color(Color::Blue),
text(cf.mock ? " MOCK (simulated)" : " Vimba X") | dim}));
if (cf.cameras.empty()) rows.push_back(text("(no cameras configured)") | dim);
for (const auto& v : cf.cameras) {
rows.push_back(hbox({
@ -513,12 +486,8 @@ 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(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(" encoding", text("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")
: std::to_string(v.rotation_deg) + " deg CW")));
@ -534,7 +503,6 @@ 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,9 +58,6 @@ 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,7 +1,5 @@
#include <doctest/doctest.h>
#include <clocale>
#include "fgc/ChannelHistory.h"
#include "fgc/ChannelTopics.h"
@ -84,52 +82,4 @@ 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);
}

View File

@ -1,239 +0,0 @@
#include "fgc/CompositeCameraSource.h"
#include <doctest/doctest.h>
#include <chrono>
#include <stdexcept>
#include <thread>
#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;
bool frame_ready = true; // frameReady()
int moves = 0;
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; }
void moving() override { ++moves; }
bool frameReady() override { return frame_ready; }
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("every frame keeps the time it arrived, the follower's too") {
Rig r;
REQUIRE(r.src->trigger());
REQUIRE(r.frames.size() == 2);
CHECK(r.frames[0].timestamp_ms == 1000);
CHECK(r.frames[1].timestamp_ms == 5000); // its own, not the leader's
}
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("a picture waits a moment for a follower between frames, then goes without it") {
auto c = std::make_unique<FakeSource>(1, 1000);
auto t = std::make_unique<FakeSource>(1, 5000);
FakeSource* colour = c.get();
FakeSource* thermal = t.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}, 60);
std::vector<Frame> frames;
src.setFrameCallback([&](const Frame& f) { frames.push_back(f); });
// The thermal camera is in its NUC: the picture is held, the leader not asked.
thermal->frame_ready = false;
CHECK_FALSE(src.trigger());
CHECK(colour->triggers == 0);
CHECK_FALSE(src.frameReady());
// It comes back within the wait: one whole pair.
thermal->frame_ready = true;
REQUIRE(src.trigger());
CHECK(frames.size() == 2);
// It does not come back: after the wait the colour picture goes alone.
frames.clear();
thermal->frame_ready = false;
CHECK_FALSE(src.trigger());
std::this_thread::sleep_for(std::chrono::milliseconds(80));
REQUIRE(src.trigger());
CHECK(colour->triggers == 2);
}
TEST_CASE("every source hears that the gimbal moves") {
Rig r;
r.src->moving();
CHECK(r.colour->moves == 1);
CHECK(r.thermal->moves == 1);
}
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({})));
// An unknown backend; accepting the key silently would be worse.
// No second backend exists yet; 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,96 +288,6 @@ 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");
CHECK(tir.nuc_interval_s == 30);
// [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");
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.tir.nuc_interval_s", "-1"}})));
CHECK_NOTHROW(ConfigLoader::fromMap(base({{"Camera.tir.nuc_interval_s", "0"}})));
// 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,7 +5,6 @@
#include <doctest/doctest.h>
#include <chrono>
#include <cstring>
#include <filesystem>
#include <mutex>
#include <thread>
@ -66,24 +65,6 @@ 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)
@ -186,86 +167,6 @@ 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,21 +91,6 @@ 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::Mono16})
PixelLayout::BayerRG12p})
CHECK(layoutFromName(layoutName(l)) == l);
CHECK(layoutFromName("BayerRG12") == PixelLayout::Unknown); // unpacked: not handled
CHECK(layoutFromName("YCbCr422_8_CbYCrY") == PixelLayout::Unknown);
@ -45,19 +45,6 @@ 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

@ -26,7 +26,6 @@ struct FakeMotor : IMotorController {
struct FakeCamera : ICameraSource {
int triggers = 0;
int moves = 0;
bool ok = true;
// A stream of frames for the keep rule; empty = a camera with no frame data.
std::vector<FrameStats> stream;
@ -45,7 +44,6 @@ struct FakeCamera : ICameraSource {
void start() override {}
void stop() override {}
bool trigger() override { ++triggers; return ok; }
void moving() override { ++moves; }
void setFrameCallback(FrameCallback) override {}
int cameraCount() const override { return 1; }
};
@ -87,7 +85,6 @@ TEST_CASE("CaptureScheduler sweeps the scan grid with MOVE + settle + trigger")
CHECK(motor.cmds[0] == "MOVE 0,0");
CHECK(sch.moving());
CHECK(cam.triggers == 0); // not yet settled
CHECK(cam.moves == 1); // the cameras hear of every move (the thermal NUC)
// At rest early: the frame waits for the interval, counted from the mode start.
settleAt(motor, 0, 0);
@ -106,7 +103,6 @@ TEST_CASE("CaptureScheduler sweeps the scan grid with MOVE + settle + trigger")
clock = 1010;
sch.tick();
CHECK(motor.cmds.back() == "MOVE 900,0");
CHECK(cam.moves == 2);
CHECK(sch.overruns() == 0);
}

View File

@ -1,478 +0,0 @@
#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)
std::atomic<int> nuc_interval{30}; // the camera's own NUC timer, s
std::atomic<int> nucs{0}; // NUC_Perform calls
std::atomic<int> nuc_mode{0};
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; }
if (what == param::kNucInterval && type == kTypeInt32) { *static_cast<int32_t*>(data) = g->nuc_interval; 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;
if (what == param::kNucInterval && type == kTypeInt32) {
g->nuc_interval = *static_cast<int32_t*>(data);
return kSuccess;
}
if (what == param::kNucPerform && type == kType2Int32) {
// As the camera: the call returns when the NUC is done.
g->nuc_mode = static_cast<Int2*>(data)->value1;
++g->nucs;
std::this_thread::sleep_for(std::chrono::milliseconds(20));
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());
// The rate is set by restarting the grab once the first frame is in.
CHECK(waitFor([&] { std::lock_guard<std::mutex> l(t.sdk.m); return t.sdk.rate > 49.f; }));
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("the program takes over the camera's NUC timer and hands it back") {
{
Harness t;
t.cam.open();
t.sdk.push(29315);
REQUIRE(t.online());
CHECK(t.sdk.nuc_interval == 0); // the camera's own timer is off
t.cam.close();
CHECK(t.sdk.nuc_interval == 30); // and back as found
}
{
auto o = fastOptions();
o.nuc_interval_ms = 0; // leave it to the camera
Harness t(o);
t.cam.open();
t.sdk.push(29315);
REQUIRE(t.online());
t.cam.moving();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
CHECK(t.sdk.nuc_interval == 30);
CHECK(t.sdk.nucs == 0);
}
}
TEST_CASE("a move runs a due NUC, and no more often than the interval") {
auto o = fastOptions();
o.nuc_interval_ms = 400;
Harness t(o);
t.cam.open();
t.cam.start();
t.sdk.push(29315);
REQUIRE(t.online());
CHECK(t.sdk.nucs == 0);
t.cam.moving(); // the first move after connecting runs one
CHECK(waitFor([&] { return t.sdk.nucs == 1; }, 1000));
CHECK(t.sdk.nuc_mode == kNucMode1Point);
t.cam.moving(); // not due yet
std::this_thread::sleep_for(std::chrono::milliseconds(150));
CHECK(t.sdk.nucs == 1);
std::this_thread::sleep_for(std::chrono::milliseconds(300));
t.cam.moving(); // due again
CHECK(waitFor([&] { return t.sdk.nucs == 2; }, 1000));
}
TEST_CASE("an overdue NUC runs without a move") {
auto o = fastOptions();
o.nuc_interval_ms = 100;
Harness t(o);
t.cam.open();
t.sdk.push(29315);
REQUIRE(t.online());
// Twice the interval with no move at all (manual mode, say).
CHECK(waitFor([&] { return t.sdk.nucs >= 1; }, 1500));
}
TEST_CASE("after a NUC only a frame from after it is taken") {
auto o = fastOptions();
o.nuc_interval_ms = 400;
Harness t(o);
t.cam.open();
t.cam.start();
t.sdk.push(29315);
REQUIRE(t.online());
CHECK(t.cam.frameReady());
t.sdk.frozen = true; // nothing new comes during and after the NUC
t.cam.moving();
REQUIRE(waitFor([&] { return t.sdk.nucs == 1; }, 1000));
std::this_thread::sleep_for(std::chrono::milliseconds(50));
CHECK_FALSE(t.cam.frameReady()); // the frame from before the NUC is stale
CHECK_FALSE(t.cam.trigger());
t.sdk.frozen = false;
t.sdk.push(29400);
CHECK(waitFor([&] { return t.cam.frameReady(); }, 1000));
CHECK(t.shot());
}
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,7 +4,6 @@
#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"
@ -204,32 +203,6 @@ 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,9 +463,6 @@ 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

@ -1,92 +0,0 @@
#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);
}