diff --git a/CMakeLists.txt b/CMakeLists.txt index 041855a..0b045be 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -94,6 +94,10 @@ add_library(fgc_core STATIC src/core/ChannelHistory.cpp src/core/ChannelTopics.cpp src/core/RawImage.cpp + src/core/ThermalStats.cpp + src/camera/IrbgrabApi.cpp + src/camera/TarisCameraSource.cpp + src/camera/CompositeCameraSource.cpp src/core/SettleRule.cpp src/core/ThermalGuard.cpp src/sensors/I2cBus.cpp @@ -108,7 +112,7 @@ target_include_directories(fgc_core PUBLIC ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR} # for the bundled ini.h ) -target_link_libraries(fgc_core PUBLIC Threads::Threads) +target_link_libraries(fgc_core PUBLIC Threads::Threads ${CMAKE_DL_LIBS}) # PUBLIC so the application and the tests both see the same build identity. target_compile_definitions(fgc_core PUBLIC FGC_GIT_REV="${FGC_GIT_REV}") diff --git a/config/config.example.ini b/config/config.example.ini index fd1bf5d..b4209f4 100644 --- a/config/config.example.ini +++ b/config/config.example.ini @@ -70,7 +70,7 @@ demosaic = edge_aware ; generated human-readable record of the same settings; it configures nothing. [Camera.rgb] enabled = true -driver = vimba ; only vimba is implemented +driver = vimba ; vimba (colour) or taris (thermal, below) id = DEV_0000000000 ; USB DEV_ id, or a GigE IP label = RGB ; output subdir + CamEvent "cam" profile_dir = camera_profiles/rgb @@ -98,6 +98,27 @@ settle_rule = brightness settle_wait_ms = 2500 frame_timeout_ms = 5000 +; --- A thermal camera (InfraTec TarisIR mini), beside the colour one --------- +; It takes its picture at the same waypoints and intervals, right after the +; colour camera, and both carry the same timestamp: RGB/.jxl and TIR/.jxl. +; The counts are saved 16-bit and always lossless (jxl_distance must stay 0); +; T[K] = count x 0.01 under profile 0, x 0.02 under profile 1, emissivity 1. +; Its driver, id (the serial) and interface belong in hardware.ini; [Hardware] +; cameras lists it. Needs the InfraTec SDK and licence on the tower, and +; cap_net_raw on the binary (scripts/fgc-setcap, which deploy.sh runs) - see +; docs/tower-installation.md. No settings XML: profile only names the timing. +;[Camera.tir] +;label = TIR +;calib_profile = 0 ; 0: -40..120 C (forest), 1: 0..600 C +;frame_rate_hz = 5 ; the camera free-runs at this, throttled +;max_frame_age_ms = 400 ; older is not kept; below the scan's settle wait +;rotation_deg = 0 +;jxl_effort = 3 +; +;[Camera.tir.standard] ; optional: without it 0 / 0, as it should be +;settle_wait_ms = 0 ; it takes the frame it already has +;frame_timeout_ms = 0 + [Serial] ; Motor-controller serial device and baud rate. device = /dev/ttyACM0 diff --git a/config/hardware.example.ini b/config/hardware.example.ini index fc75b3d..f72be14 100644 --- a/config/hardware.example.ini +++ b/config/hardware.example.ini @@ -53,7 +53,8 @@ env = sht41 ; The legacy towers: off to 25 C, +20/255 per degree, full from 37.75 C, and full ; whenever the sensor cannot be read. Needs env = dht11 and [Pins] fan_pwm. ;cooling = fan -; The [Camera.] blocks expected to be present and found. +; The [Camera.] blocks expected to be present and found ("rgb, tir" with +; the thermal camera). cameras = rgb ; How much bearing one picture covers, in degrees. Pan mode ('mode pan') cuts ; its span into as many pictures as this takes. 6 until the lens is measured. @@ -64,6 +65,7 @@ pan_step_deg = 6 ; (N5105) needs 2 workers for that. jxl_threads is libjxl's threads per frame; ; 0 or absent = 4 / save_workers. save_queue_max is how many frames may wait ; or be saving before the scan holds at its waypoint (each raw frame ~24 MB). +; A thermal camera adds a frame per picture (0.6 MB): raise it by 2. save_workers = 1 ;jxl_threads = 0 save_queue_max = 4 @@ -247,3 +249,11 @@ has_encoder = true ; The serial of the physical camera fitted to this tower. A USB DEV_ id, or a ; GigE IP address. id = DEV_1AB22C049CD1 + +; A thermal camera (TarisIR mini): its serial number, as the SDK's search finds +; it, and the network port it is cabled to (static address, no DHCP, MTU 1500; +; only used to say what is wrong when the camera is not found). +;[Camera.tir] +;driver = taris +;id = 2017744 +;interface = enp1s0 diff --git a/deploy.sh b/deploy.sh index e74f241..55d7b30 100755 --- a/deploy.sh +++ b/deploy.sh @@ -308,6 +308,10 @@ ssh "$REMOTE_HOST" "$RESOLVE_CMAKE $CHECK_DEPS_CMD cd '$REMOTE_DIR' && \ GIT_REV='$GIT_REV' \"\$CMAKE\" -S . -B build $CMAKE_ARGS && \ \"\$CMAKE\" --build build $JOBS_FLAG" echo ">> build complete: $REMOTE_DIR/build/fire_gimbal_control" +# The rebuilt binary has lost its capabilities; a thermal camera needs +# cap_net_raw back (scripts/fgc-setcap). Without a thermal camera: nothing. +# shellcheck disable=SC2029 +ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && scripts/fgc-setcap --if-thermal" || true # A pushed config.ini was written on another machine: check that it loads on # this one, with this tower's hardware.ini layered over it, before anyone runs it. diff --git a/docs/architecture.md b/docs/architecture.md index 4079304..fe95cea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,7 +17,7 @@ The design separates **policy** (the control logic) from **mechanism** (the I/O |-----------|------|-------------| | `IMotorController` | `SerialMotorController` (Boost.Asio) | `MockMotorController` (simulated sweep) | | `IControlChannel` | `MqttControlChannel` (Paho) | `NullControlChannel` (no broker) | -| `ICameraSource` | `VimbaCameraSource` (Vimba X) | `MockCameraSource` (synthetic frames) | +| `ICameraSource` | `VimbaCameraSource` (Vimba X), `TarisCameraSource` (InfraTec TarisIR, thermal), both behind `CompositeCameraSource` | `MockCameraSource` (synthetic frames, also thermal counts) | | `IImuSource` | `MtiImuSource` (Xsens MTi, Boost.Asio) | `MockImuSource` (synthetic orientation) | | `IEnvSensor` | `Sht41EnvSensor` (SHT41 over I2C) | `MockEnvSensor` (synthetic temp/humidity) | @@ -54,6 +54,7 @@ thread so all geometry/state mutation stays single-threaded. | Image worker | `ImagePipeline` | drains the frame queue: rotate → encode → write → publish | | MQTT client | Paho (internal) | delivers callbacks; auto-reconnects (no busy-wait loop) | | Camera acquisition | Vimba X (internal) | free-runs at the mode's frame rate and delivers frames via the observer, which keeps the latest complete one | +| Thermal acquisition | `TarisCameraSource` worker | owns every SDK call: connects (and reconnects), polls the newest frame of counts, switches the calibration profile; `trigger()` only takes the kept frame | Shared state is mutex-guarded: latest `MotorTelemetry` (serial), `RemoteCommand` queue (channel), the frame queue (pipeline), the console command queue, and the latest `UiSnapshot`. The `CaptureScheduler` runs only on the @@ -132,6 +133,23 @@ everything below is an implementation detail of the camera source. That is what ## Image capture +### Two cameras, one array: `CompositeCameraSource` + +With a thermal camera the scheduler and the pipeline still see one +`ICameraSource`. The composite numbers the colour cameras first (camera 0 is +the one the keep rule reads) and the thermal one after them. Its `trigger()` +asks the colour source first; only if that delivered does it ask the thermal +source, which hands over its newest frame if it is no older than +`max_frame_age_ms` and otherwise says no at once. Every frame of one trigger +takes the colour frame's timestamp. The colour camera failing stops the run as +before; the thermal one failing never does (`offline()` says why, Health +`cams_offline` names it). Thermal frames are `Mono16` counts: the pipeline +rotates them, takes `tmax`/`tmed`/hot spot on the rotated image +(`ThermalStats.h`), and saves them 16-bit lossless whatever `jxl_distance` says. +The SDK is declared in `IrbgrabApi.h` (not copied: its licence forbids that) and +loaded with `dlopen`, so every build has the thermal code and its tests run +against a fake SDK. + ### The model: free-run, then hold still The camera **runs continuously** at the mode's frame rate with its own auto-exposure and auto-gain in diff --git a/docs/configuration.md b/docs/configuration.md index 63a921b..4c9f794 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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.` | `id` | string | — | USB `DEV_` id or GigE IP. Required when enabled | -| `Camera.` | `driver` | `vimba` | `vimba` | Backend. Only `vimba` is implemented | +| `Camera.` | `id` | string | — | `vimba`: USB `DEV_` id or GigE IP; `taris`: the camera's serial number. Required when enabled | +| `Camera.` | `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.` | `label` | string | the block name | Output subdirectory and `CamEvent` `cam` field | -| `Camera.` | `profile_dir` | string | — | Directory of profile XMLs; a profile named X is `/X.xml`. A relative path resolves against the directory holding the `config.ini` in use | +| `Camera.` | `profile_dir` | string | — | `vimba` only. Directory of profile XMLs; a profile named X is `/X.xml`. A relative path resolves against the directory holding the `config.ini` in use | | `Camera.` | `profile` | string | `standard` | Profile to start in. Switch live with `set camera profile [] ` | | `Camera.` | `enabled` | bool | `true` | A disabled camera is not opened and does not affect the scan timing | | `Camera.` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to that camera's saved images | @@ -75,6 +75,11 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config | `Camera` | `jxl_distance` / `jxl_effort` | double ≥ 0 / int 1..9 | `0.8` / `4` | Encoder defaults inherited by every camera block | | `Camera.` | `jxl_distance` / `jxl_effort` | double ≥ 0 / int 1..9 | the `[Camera]` value | Per-camera override of the encoder settings. Distance 0 is truly lossless | | `Camera` / `Camera.` | `demosaic` | `edge_aware` \| `bilinear` | `edge_aware` | How raw profiles (`BayerRG8`, `BayerRG12p`) are converted to colour on the tower | +| `Camera.` (`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 ` | +| `Camera.` (`taris`) | `frame_rate_hz` | 0 < x ≤ 50 | `5` | The rate the camera free-runs at | +| `Camera.` (`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.` (`taris`) | `library` | string | `libirbgrab_linux.so` | The InfraTec SDK, found through the ld cache | +| `Camera.` (`taris`, hardware.ini) | `interface` | string | — | The network port the camera is cabled to; only named in the message when it is not found | | `Hardware` (hardware.ini) | `save_workers` | int 1..4 | `1` | Frames saved at once. 1 on the IOTA, 2 on the 3 Delta ([measurements](raw12-pipeline-measurements.md)) | | `Hardware` (hardware.ini) | `jxl_threads` | int 0..8 | `0` = 4 / `save_workers` | libjxl threads per saved frame | | `Hardware` (hardware.ini) | `save_queue_max` | int 1..32 | `4` | Frames waiting or being saved before the scan holds at its waypoint (a raw 16 MP frame is ~24 MB) | @@ -1054,3 +1059,29 @@ grid (front to back, repeating); in **pan** mode it walks the pan's headings lef pitch, then starts again from the left edge (see [mqtt-api.md](mqtt-api.md#modes-auto-pan-manual)). Telemetry arrives as firmware `ST` lines (per-axis state + encoder counts), parsed by [TelemetryParser](../src/core/TelemetryParser.cpp). + +## Thermal camera (`driver = taris`) + +A TarisIR mini beside the colour camera takes its picture at the same waypoints +and intervals: the colour camera is asked first, and only once it has delivered +is the thermal one asked for its newest frame. Both frames carry the colour +frame's timestamp, so `RGB/.jxl` and `TIR/.jxl` are a pair (and so are +their CamEvents, see [mqtt-api.md](mqtt-api.md#thermal-camera-cam-tir)). + +- **Best effort.** A thermal camera that is missing, unlicensed or has no fresh + frame costs that picture its thermal image, nothing more: the scan goes on, the + camera reconnects by itself, and Health `cams_offline` names it meanwhile. +- **Always lossless.** The counts are measurements; `jxl_distance` must be 0 + (the default for this driver) and the loader refuses anything else. +- **No settings XML.** The camera is set up from its keys; `profile` only picks a + `[Camera..]` timing block, which it does not need: without one + its wait is 0 and the colour camera's settle wait alone sets the pace. The + frame it hands over is at most `max_frame_age_ms` old, so that must stay below + the colour camera's `settle_wait_ms`. +- **Kept grabbing**: from start to exit, also in manual mode. A microbolometer + that is restarted begins with zero frames and a fresh NUC. +- **Split between the files** like any camera: `driver`, `id` and `interface` + in hardware.ini (with `tir` in `[Hardware] cameras`), the rest in config.ini. + `save_queue_max` counts frames, so raise it by 2. +- The binary needs `cap_net_raw` (the SDK uses a raw socket): + [tower-installation.md](tower-installation.md#phase-2b---thermal-camera). diff --git a/docs/known-issues.md b/docs/known-issues.md index efff870..4462a45 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -166,8 +166,13 @@ scripts for this are worth keeping; note that wrapping them in `timeout` SIGTERM key features (`AcquisitionFrameRate`, `Width`, `PixelFormat`) would turn a silent misconfiguration into an error. - Graceful shutdown on SIGINT (currently exit via `exit`/Ctrl-D; a pending `getline` can delay shutdown). -- Support a second camera backend. `driver` is parsed and validated but only `vimba` is accepted, so a - thermal or NIR unit needs a new `ICameraSource` implementation behind that key. +- ~~Support a second camera backend.~~ Done for the thermal camera (`driver = taris`, + `TarisCameraSource`, behind `CompositeCameraSource`). Open on the tower: whether `cap_net_raw` alone is + enough for the SDK, whether `Framerate_Hz` is settable, and whether `MissedPackets` is per frame + (docs/tower-installation.md, Phase 2b). A NIR unit would be a third driver behind the same key. +- Per-channel boresight: `[Motor]` heading and tilt calibration belong to the tower, but the thermal camera + sits on the gimbal with its own small offset against the colour one. `hotx`/`hoty` are therefore in the + thermal image's own frame; projecting them onto terrain needs a per-camera offset nobody has measured yet. - Reintroduce optional image upload to the ground station, config-driven (the old hardcoded NFS/SMB upload was removed). - **Calibration persistence needs the TUI.** `gimbal pitch setup` and `gimbal heading fit` are always diff --git a/docs/mqtt-api.md b/docs/mqtt-api.md index d50fd6c..d47b0aa 100644 --- a/docs/mqtt-api.md +++ b/docs/mqtt-api.md @@ -95,7 +95,7 @@ Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`: | Field | Type | Meaning | |-------|------|---------| | `fwt` | string | Tower name (`config.ini` `tower_name`) | -| `cam` | string | Camera label: `RGB`, `ACR`, or `NIR` (by camera index) | +| `cam` | string | Camera label (`[Camera.] label`): `RGB`, `TIR`, `ACR` or `NIR` | | `hdg` | int | Gimbal **true bearing** × 10 (0 = north) at capture time, from the **yaw encoder** | | `pit` | int | Gimbal pitch elevation **× 10** at capture time, from the **IMU** — elevation relative to gravity, i.e. true pointing angle (fallbacks below) | | `time` | int | Capture timestamp, Unix epoch **milliseconds** (matches the `.jxl` filename) | @@ -135,6 +135,40 @@ Both `hdg` and `pit` are sampled **when the frame arrives**, not when its JPEG X finishes, so a slow encode or a backed-up queue cannot attribute a later pose to earlier pixels. +#### Thermal camera (`cam: "TIR"`) + +A thermal camera's events carry six more fields; a colour camera's never do, and +their payload is unchanged. + +```json +{ "fwt":"ExampleTower", "cam":"TIR", "hdg":1373, "pit":300, "time":1719312345678, + "cal":0, "missed":0, "tmax":3015, "tmed":187, "hotx":0.7359, "hoty":0.5833 } +``` + +| Field | Type | Meaning | +|-------|------|---------| +| `cal` | int | Calibration profile the image was taken on. **Required to read the file**: it sets the counts' scale | +| `missed` | int | GigE packets the SDK lost for this frame (transfer quality) | +| `tmax` | int/null | Hottest pixel, **apparent** temperature in tenths of a °C | +| `tmed` | int/null | Median of the image, tenths of a °C (the anchor for a display window) | +| `hotx`, `hoty` | number/null | Where the hottest pixel is: its centre, 0..1 across and down the image **as stored** (after `rotation_deg`), as for detection boxes | + +- **Same `time` as the colour picture.** The thermal camera is asked right after + the colour one, at the same waypoint, and its event and file take the colour + frame's timestamp: `RGB/