Fix what the thermal camera's first run on tower 111 showed
- The thermal frame was numbered camera 0 - label RGB, and the colour file of the same time overwritten - because Vimba counts no camera before open(). The composite now takes each source's configured camera count, and two enabled cameras may no longer share a label. - The SDK switches LC_NUMERIC to de_DE when loaded, which made "hotx":0,4336. Its calls now run under a guard that restores the locale, and the JSON's numbers no longer go through printf. - The camera's frame counter is always 0: new frames are told by their time, so the stream no longer stops after the first one. - The LUT is empty for the first frame: it is asked again on later ones. - The SDK is libirbgrab_x86_64-linux.so in the ld cache. - The null channel traces the CamEvent payload it would have sent. Tested on tower 111 with the TarisIR mini, the motors mocked: cap_net_raw is enough, RGB/TIR pairs with one timestamp, the TIR file decodes to the counts its CamEvent describes, profile switches 0-1-0. 536/536 in the 22.04 container. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3d90af8490
commit
474e3cac8a
|
|
@ -78,7 +78,7 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
|
|||
| `Camera.<name>` (`taris`) | `calib_profile` | int ≥ 0 | `0` | Calibration profile set on connect: 0 = -40..120 °C at 0.01 K/count, 1 = 0..600 °C at 0.02 K/count. Switch live with `camera calib <n>` |
|
||||
| `Camera.<name>` (`taris`) | `frame_rate_hz` | 0 < x ≤ 50 | `5` | The rate the camera free-runs at |
|
||||
| `Camera.<name>` (`taris`) | `max_frame_age_ms` | int > 0 | `400` | A frame older than this when the picture is taken is not kept: that picture has no thermal image. Keep it below the scan's settle wait, or the frame can predate standstill (warned at startup) |
|
||||
| `Camera.<name>` (`taris`) | `library` | string | `libirbgrab_linux.so` | The InfraTec SDK, found through the ld cache |
|
||||
| `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 |
|
||||
|
|
|
|||
|
|
@ -167,9 +167,13 @@ scripts for this are worth keeping; note that wrapping them in `timeout` SIGTERM
|
|||
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`). 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.
|
||||
`TarisCameraSource`, behind `CompositeCameraSource`), tested with the TarisIR mini on tower 111 on
|
||||
2026-10-14 (docs/tower-installation.md, Phase 2b). Not yet tested: a pulled camera cable during a scan
|
||||
(the reconnect is only unit-tested), and a long unattended run. A NIR unit would be a third driver
|
||||
behind the same key.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -939,9 +939,21 @@ 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.
|
||||
|
||||
Still to confirm on the tower (2026-10-14): that `cap_net_raw` is enough (if
|
||||
the SDK also wants `cap_net_admin`, add it in `scripts/fgc-setcap`), that the
|
||||
frame rate is settable, and that `MissedPackets` counts per frame.
|
||||
Measured on tower 111 (2026-10-14, SDK 4.3.44, SN 2017744):
|
||||
|
||||
- `cap_net_raw` alone is enough; no root, no `cap_net_admin`. Vimba X still
|
||||
opens the colour camera from the capability binary.
|
||||
- Connecting takes about 15 s (SDK init and search); the scan does not wait.
|
||||
- `Framerate_Hz` is settable. The LUT is empty for the first frame after
|
||||
connecting and right from then on: 0.0099998 K/count (profile 0), 0.0199995
|
||||
(profile 1).
|
||||
- A profile switch takes about 3 s; frames keep the old scale until
|
||||
`Calib_ChangeInProgress` reaches 0, and the program takes none meanwhile.
|
||||
- The camera's frame counter (`ImageNum`) is always 0; `MissedPackets` was
|
||||
always 0 on the direct link.
|
||||
- The SDK switches the process's C locale to `LC_NUMERIC=de_DE` when it is
|
||||
loaded. The program puts it back after every SDK call and writes its JSON
|
||||
without the C library's number formatting.
|
||||
|
||||
### Phase 3 - the broker
|
||||
|
||||
|
|
|
|||
|
|
@ -24,9 +24,16 @@ namespace fgc {
|
|||
//
|
||||
// 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);
|
||||
explicit CompositeCameraSource(std::vector<std::unique_ptr<ICameraSource>> children,
|
||||
std::vector<int> counts = {});
|
||||
|
||||
void open() override;
|
||||
void close() override;
|
||||
|
|
@ -55,6 +62,7 @@ private:
|
|||
ICameraSource* route(int camera_index, int& local) const;
|
||||
|
||||
std::vector<std::unique_ptr<ICameraSource>> children_;
|
||||
std::vector<int> counts_; // cameras each child is configured with
|
||||
std::vector<int> offsets_; // global index of each child's camera 0
|
||||
FrameCallback callback_;
|
||||
bool in_trigger_ = false;
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ struct CameraSpec {
|
|||
// 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_linux.so"; // the SDK, found through the ld cache
|
||||
std::string library = "libirbgrab_x86_64-linux.so"; // the SDK, found through the ld cache
|
||||
// Mounting (hardware.ini): the camera's network port, for diagnostics only.
|
||||
std::string interface;
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ public:
|
|||
int calib_profile = 0;
|
||||
double frame_rate_hz = 5.0;
|
||||
int max_frame_age_ms = 400;
|
||||
std::string library = "libirbgrab_linux.so";
|
||||
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...
|
||||
|
|
|
|||
|
|
@ -33,10 +33,9 @@ public:
|
|||
drop(kTopicStatusCode, payload);
|
||||
}
|
||||
void publishCamEvent(const CamEvent& e) override {
|
||||
LOG_TRACE_CAT(LogCat::Mqtt) << "PUB CamEvent cam=" << e.camera
|
||||
<< " hdg=" << e.heading_decideg << " t=" << e.timestamp_ms
|
||||
<< " (null channel)";
|
||||
drop(kTopicCamEvent, camEventJson(e));
|
||||
const std::string payload = camEventJson(e);
|
||||
LOG_TRACE_CAT(LogCat::Mqtt) << "PUB CamEvent " << payload << " (null channel)";
|
||||
drop(kTopicCamEvent, payload);
|
||||
}
|
||||
void publishHealth(const HealthReport& h) override {
|
||||
++health_published_;
|
||||
|
|
|
|||
|
|
@ -6,13 +6,18 @@
|
|||
|
||||
namespace fgc {
|
||||
|
||||
CompositeCameraSource::CompositeCameraSource(std::vector<std::unique_ptr<ICameraSource>> children)
|
||||
: children_(std::move(children)) {
|
||||
CompositeCameraSource::CompositeCameraSource(std::vector<std::unique_ptr<ICameraSource>> children,
|
||||
std::vector<int> counts)
|
||||
: children_(std::move(children)), counts_(std::move(counts)) {
|
||||
if (children_.empty()) throw std::invalid_argument("CompositeCameraSource needs a camera");
|
||||
if (counts_.empty())
|
||||
for (const auto& c : children_) counts_.push_back(c->cameraCount());
|
||||
if (counts_.size() != children_.size())
|
||||
throw std::invalid_argument("CompositeCameraSource: one camera count per source");
|
||||
int next = 0;
|
||||
for (const auto& c : children_) {
|
||||
for (int n : counts_) {
|
||||
offsets_.push_back(next);
|
||||
next += c->cameraCount();
|
||||
next += n;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -20,7 +25,7 @@ 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 < children_[k]->cameraCount() ? children_[k].get() : nullptr;
|
||||
return local < counts_[k] ? children_[k].get() : nullptr;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
|
@ -100,8 +105,12 @@ bool CompositeCameraSource::readFeature(int camera_index, const std::string& nam
|
|||
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 {
|
||||
return offsets_.back() + children_.back()->cameraCount();
|
||||
int n = 0;
|
||||
for (const auto& c : children_) n += c->cameraCount();
|
||||
return n;
|
||||
}
|
||||
|
||||
std::vector<CameraDeviceInfo> CompositeCameraSource::deviceInfo() {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
#include <condition_variable>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <clocale>
|
||||
#include <fstream>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
|
|
@ -46,6 +47,27 @@ std::string sysfs(const std::string& path) {
|
|||
return s;
|
||||
}
|
||||
|
||||
// The SDK changes the process's C locale (seen on tower 111: LC_NUMERIC turned
|
||||
// to a comma one under LANG=en_US.UTF-8). The program parses and prints numbers
|
||||
// with the C library, so every SDK call that may do this runs inside one of
|
||||
// these, which puts the locale back as it was.
|
||||
class LocaleGuard {
|
||||
public:
|
||||
LocaleGuard() {
|
||||
const char* now = std::setlocale(LC_ALL, nullptr);
|
||||
saved_ = now ? now : "C";
|
||||
}
|
||||
~LocaleGuard() {
|
||||
const char* now = std::setlocale(LC_ALL, nullptr);
|
||||
if (!now || saved_ != now) std::setlocale(LC_ALL, saved_.c_str());
|
||||
}
|
||||
LocaleGuard(const LocaleGuard&) = delete;
|
||||
LocaleGuard& operator=(const LocaleGuard&) = delete;
|
||||
|
||||
private:
|
||||
std::string saved_;
|
||||
};
|
||||
|
||||
constexpr long long kLogEveryMs = 60000; // repeated trouble: one line a minute
|
||||
constexpr long long kTelemetryEveryMs = 5000;
|
||||
|
||||
|
|
@ -97,7 +119,8 @@ struct TarisCameraSource::Impl {
|
|||
int cal = -1; // active profile as the camera reports it
|
||||
double kpc = 0.0; // measured from the LUT; 0 = not yet
|
||||
long long last_frame_steady = 0;
|
||||
long long last_image_num = -1;
|
||||
double last_sdk_ts = -1.0; // MemInfo time of the last frame taken
|
||||
int scale_tries = 0; // frames whose LUT was still empty
|
||||
long long last_telemetry = 0;
|
||||
long long nuc_or_licence = 0; // getdata 0x80000017 since the last log line
|
||||
long long nuc_logged_ms = 0;
|
||||
|
|
@ -185,15 +208,23 @@ struct TarisCameraSource::Impl {
|
|||
}
|
||||
Ret setInt32(uint32_t what, int32_t v) { return api->dev_setparam(dev, what, &v, kTypeInt32); }
|
||||
|
||||
// Calib_Active is an indexed value; read it plainly if the camera prefers.
|
||||
// Calib_Active reads as a plain or an indexed value (both work on 4.3.44).
|
||||
// Right after a reconnect it can fail for a moment, so it is asked a few times.
|
||||
int readCal() {
|
||||
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||
int32_t v = -1;
|
||||
if (getIdxInt32(param::kCalibActive, 0, v)) return v;
|
||||
if (getInt32(param::kCalibActive, v)) return v;
|
||||
if (getInt32(param::kCalibActive, v) && v >= 0) return v;
|
||||
if (getIdxInt32(param::kCalibActive, 0, v) && v >= 0) return v;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Switch the calibration profile and wait for the camera to finish.
|
||||
// Calib_Active reports the new profile at once, but the frames keep the old
|
||||
// scale until Calib_ChangeInProgress has run 2, 4, 2 and reached 0 (about
|
||||
// 3 s on tower 111; at rest, before any switch, it reads 6). Nothing is
|
||||
// polled meanwhile, so no frame of the wrong scale is taken.
|
||||
bool switchCal(int profile, std::string& why) {
|
||||
int32_t count = 0;
|
||||
if (getInt32(param::kCalibCount, count) && (profile < 0 || profile >= count)) {
|
||||
|
|
@ -219,7 +250,8 @@ struct TarisCameraSource::Impl {
|
|||
return false;
|
||||
}
|
||||
cal = readCal();
|
||||
kpc = 0.0; // measure the new scale from the next frame's LUT
|
||||
kpc = 0.0; // measure the new scale from the next frames' LUT
|
||||
scale_tries = 0;
|
||||
if (cal != profile) {
|
||||
why = "camera reports profile " + std::to_string(cal) + " after switching to " +
|
||||
std::to_string(profile);
|
||||
|
|
@ -336,6 +368,11 @@ struct TarisCameraSource::Impl {
|
|||
|
||||
// Profile: it sets the counts' scale, so it is checked before any frame.
|
||||
cal = readCal();
|
||||
if (cal < 0) {
|
||||
disconnect();
|
||||
why = "cannot read the active calibration profile";
|
||||
return false;
|
||||
}
|
||||
if (cal != o.calib_profile) {
|
||||
LOG_INFO << "thermal camera on calibration profile " << cal << ", switching to "
|
||||
<< o.calib_profile;
|
||||
|
|
@ -371,7 +408,8 @@ struct TarisCameraSource::Impl {
|
|||
++connects;
|
||||
}
|
||||
kpc = 0.0;
|
||||
last_image_num = -1;
|
||||
scale_tries = 0;
|
||||
last_sdk_ts = -1.0;
|
||||
zeros_logged = false;
|
||||
last_frame_steady = steadyMs(); // grace for the first frame
|
||||
return true;
|
||||
|
|
@ -380,6 +418,9 @@ struct TarisCameraSource::Impl {
|
|||
// ---- frames ------------------------------------------------------------
|
||||
// Take the newest frame from the SDK, if there is one.
|
||||
void poll() {
|
||||
// As before every getdata in the vendor's examples: without it the SDK
|
||||
// now and then answers 0x80000001 instead of a frame.
|
||||
setInt32(param::kPollingEnable, 1);
|
||||
Handle mem = 0;
|
||||
const Ret r = api->dev_getdata(dev, kMemIrDigitFrame, &mem);
|
||||
if (!ok(r)) {
|
||||
|
|
@ -411,11 +452,17 @@ struct TarisCameraSource::Impl {
|
|||
const size_t need = static_cast<size_t>(w) * h * 2;
|
||||
if (bytes < 0 || static_cast<size_t>(bytes) < need) return;
|
||||
|
||||
// Polling hands out the newest frame again until a newer one arrives. The
|
||||
// camera's frame counter stays 0 (TarisIR mini, SDK 4.3.44), so a new
|
||||
// frame is told by its time.
|
||||
MemInfo mi{};
|
||||
mi.struct_size = sizeof(MemInfo);
|
||||
if (!ok(api->mem_getinfo(mem, &mi))) mi.image_num = -1;
|
||||
if (mi.image_num >= 0 && mi.image_num == last_image_num) return; // seen it
|
||||
last_image_num = mi.image_num;
|
||||
if (!ok(api->mem_getinfo(mem, &mi))) {
|
||||
mi.image_num = -1;
|
||||
mi.timestamp_ms = -1.0;
|
||||
}
|
||||
if (mi.timestamp_ms >= 0.0 && mi.timestamp_ms == last_sdk_ts) return; // seen it
|
||||
last_sdk_ts = mi.timestamp_ms;
|
||||
|
||||
const auto* counts = reinterpret_cast<const uint16_t*>(ptr);
|
||||
// The first frames after starting are all zero.
|
||||
|
|
@ -470,24 +517,33 @@ struct TarisCameraSource::Impl {
|
|||
}
|
||||
|
||||
// The counts' scale, from the LUT at emissivity and transmission 1, where it
|
||||
// is a straight line through zero.
|
||||
// is a straight line through zero. The first frames after connecting come
|
||||
// with an empty table, so it is asked again on later ones; frames meanwhile
|
||||
// use the documented scale, and if the table stays empty, so does the run.
|
||||
static constexpr int kScaleTries = 50;
|
||||
void measureScale(Handle mem) {
|
||||
std::vector<float> lut(65536, 0.f);
|
||||
const Ret r = api->mem_getdvlut(mem, lut.data(), 1.f, 1.f, 293.15f, 293.15f);
|
||||
const double expected = expectedKelvinPerCount(cal);
|
||||
if (!ok(r)) {
|
||||
LOG_WARN << "thermal camera: cannot read the LUT (" << describe(r) << "); assuming "
|
||||
<< expected << " K per count for profile " << cal;
|
||||
kpc = expected;
|
||||
return;
|
||||
}
|
||||
kpc = (static_cast<double>(lut[10000]) - lut[0]) / 10000.0;
|
||||
double measured = 0.0;
|
||||
if (ok(r)) measured = (static_cast<double>(lut[40000]) - lut[30000]) / 10000.0;
|
||||
if (!(measured > 0.0) && ++scale_tries < kScaleTries) return; // try the next frame
|
||||
LOG_DEBUG << "thermal camera LUT after " << scale_tries << " empty: " << describe(r)
|
||||
<< ", [0] " << lut[0] << " [29315] " << lut[29315] << " [65280] "
|
||||
<< lut[65280] << " [65535] " << lut[65535] << " K";
|
||||
if (!(measured > 0.0)) {
|
||||
kpc = expected > 0.0 ? expected : -1.0; // -1: unknown, and not asked again
|
||||
LOG_WARN << "thermal camera: no usable LUT (" << describe(r) << "); taking the "
|
||||
<< "documented " << expected << " K per count for profile " << cal;
|
||||
} else {
|
||||
kpc = measured;
|
||||
if (expected > 0.0 && std::abs(kpc - expected) > expected * 0.01)
|
||||
LOG_WARN << "thermal camera: profile " << cal << " has " << kpc
|
||||
<< " K per count, not the documented " << expected;
|
||||
else
|
||||
LOG_INFO << "thermal camera: profile " << cal << ", " << kpc << " K per count";
|
||||
}
|
||||
}
|
||||
|
||||
void telemetry() {
|
||||
float k = 0.f;
|
||||
|
|
@ -501,6 +557,7 @@ struct TarisCameraSource::Impl {
|
|||
void run() {
|
||||
if (!api) {
|
||||
std::string err;
|
||||
LocaleGuard keep;
|
||||
api = irbgrab::load(o.library, err);
|
||||
if (!api) {
|
||||
setOffline(err + " (InfraTec SDK not installed?)");
|
||||
|
|
@ -512,7 +569,11 @@ struct TarisCameraSource::Impl {
|
|||
"which sets it, or sudo setcap cap_net_raw+ep build/fire_gimbal_control");
|
||||
return;
|
||||
}
|
||||
Ret r = api->dll_init();
|
||||
Ret r;
|
||||
{
|
||||
LocaleGuard keep;
|
||||
r = api->dll_init();
|
||||
}
|
||||
if (!ok(r)) {
|
||||
setOffline("SDK init failed: " + describe(r));
|
||||
return;
|
||||
|
|
@ -526,6 +587,7 @@ struct TarisCameraSource::Impl {
|
|||
const long long poll_ms =
|
||||
std::max<long long>(5, static_cast<long long>(500.0 / o.frame_rate_hz));
|
||||
while (running) {
|
||||
LocaleGuard keep_locale; // every SDK call in this pass, polling included
|
||||
if (!connected) {
|
||||
{
|
||||
// Connecting is what a reset asks for; it must not keep the
|
||||
|
|
@ -534,7 +596,12 @@ struct TarisCameraSource::Impl {
|
|||
reset_wanted = false;
|
||||
}
|
||||
std::string why;
|
||||
if (connect(why)) {
|
||||
bool up;
|
||||
{
|
||||
LocaleGuard keep;
|
||||
up = connect(why);
|
||||
}
|
||||
if (up) {
|
||||
retry = o.retry_min_ms;
|
||||
std::lock_guard<std::mutex> l(m);
|
||||
why_offline = "waiting for the first frame";
|
||||
|
|
@ -563,6 +630,7 @@ struct TarisCameraSource::Impl {
|
|||
}
|
||||
pause(poll_ms);
|
||||
}
|
||||
LocaleGuard keep;
|
||||
disconnect();
|
||||
if (sdk_init) api->dll_uninit();
|
||||
sdk_init = false;
|
||||
|
|
|
|||
|
|
@ -440,11 +440,20 @@ struct Application::Impl {
|
|||
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;
|
||||
if (!colour.empty() || thermal.empty()) sources.push_back(makeColourCamera(colour, mock));
|
||||
for (const auto* c : thermal) sources.push_back(makeThermalCamera(*c));
|
||||
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));
|
||||
return std::make_unique<CompositeCameraSource>(std::move(sources), std::move(counts));
|
||||
}
|
||||
|
||||
std::unique_ptr<ICameraSource> makeColourCamera(const std::vector<const CameraSpec*>& active,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#include "fgc/ChannelTopics.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
namespace fgc {
|
||||
|
|
@ -11,10 +12,23 @@ std::string towerTopic(const std::string& tower, const std::string& leaf) {
|
|||
|
||||
namespace {
|
||||
|
||||
// Fixed-point, with a '.' whatever the C locale says. printf would follow
|
||||
// LC_NUMERIC, and the thermal camera's SDK sets that to a comma locale when it
|
||||
// loads (seen on tower 111: "hotx":0,4336) - which is no longer JSON.
|
||||
std::string num(double v, int decimals) {
|
||||
char b[32];
|
||||
std::snprintf(b, sizeof(b), "%.*f", decimals, v);
|
||||
return b;
|
||||
if (!std::isfinite(v)) return "null";
|
||||
long long scale = 1;
|
||||
for (int i = 0; i < decimals; ++i) scale *= 10;
|
||||
const long long n = std::llround(v * static_cast<double>(scale));
|
||||
const bool neg = n < 0;
|
||||
const unsigned long long a = neg ? 0ULL - static_cast<unsigned long long>(n)
|
||||
: static_cast<unsigned long long>(n);
|
||||
std::string out = (neg ? "-" : "") + std::to_string(a / static_cast<unsigned long long>(scale));
|
||||
if (decimals > 0) {
|
||||
std::string frac = std::to_string(a % static_cast<unsigned long long>(scale));
|
||||
out += "." + std::string(static_cast<size_t>(decimals) - frac.size(), '0') + frac;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
|
|
|||
|
|
@ -464,6 +464,17 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
|
|||
// 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);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
#include <doctest/doctest.h>
|
||||
|
||||
#include <clocale>
|
||||
|
||||
#include "fgc/ChannelHistory.h"
|
||||
#include "fgc/ChannelTopics.h"
|
||||
|
||||
|
|
@ -108,6 +110,24 @@ TEST_CASE("a thermal CamEvent carries profile, packets, temperatures and hot spo
|
|||
"\"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";
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ namespace {
|
|||
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;
|
||||
|
|
@ -25,6 +27,7 @@ struct FakeSource : ICameraSource {
|
|||
|
||||
void open() override {
|
||||
if (throws_on_open) throw std::runtime_error("no camera");
|
||||
opened = true;
|
||||
}
|
||||
void close() override {}
|
||||
void start() override { started = true; }
|
||||
|
|
@ -41,7 +44,7 @@ struct FakeSource : ICameraSource {
|
|||
return true;
|
||||
}
|
||||
void setFrameCallback(FrameCallback c) override { cb = std::move(c); }
|
||||
int cameraCount() const override { return count; }
|
||||
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";
|
||||
|
|
@ -143,6 +146,32 @@ TEST_CASE("calls about one camera reach the source that has it") {
|
|||
CHECK(f.timestamp_ms == 5050);
|
||||
}
|
||||
|
||||
TEST_CASE("the indices are the configured ones, also before the leader has opened") {
|
||||
// On tower 111 the thermal frame came out as camera 0 - "RGB", overwriting
|
||||
// the colour file of the same time - because Vimba counts no camera before
|
||||
// open() and the offsets were taken from that.
|
||||
auto c = std::make_unique<FakeSource>(1, 1000);
|
||||
auto t = std::make_unique<FakeSource>(1, 5000);
|
||||
c->counts_after_open = true;
|
||||
FakeSource* colour = c.get();
|
||||
std::vector<std::unique_ptr<ICameraSource>> v;
|
||||
v.push_back(std::move(c));
|
||||
v.push_back(std::move(t));
|
||||
CompositeCameraSource src(std::move(v), {1, 1});
|
||||
std::vector<Frame> frames;
|
||||
src.setFrameCallback([&](const Frame& f) { frames.push_back(f); });
|
||||
CHECK(src.cameraCount() == 1); // what is open: the thermal camera only
|
||||
src.open();
|
||||
CHECK(colour->opened);
|
||||
CHECK(src.cameraCount() == 2);
|
||||
REQUIRE(src.trigger());
|
||||
REQUIRE(frames.size() == 2);
|
||||
CHECK(frames[0].cam_id == 0);
|
||||
CHECK(frames[1].cam_id == 1);
|
||||
CHECK(src.offline(1).empty());
|
||||
CHECK_THROWS(CompositeCameraSource(std::vector<std::unique_ptr<ICameraSource>>{}, {}));
|
||||
}
|
||||
|
||||
TEST_CASE("start and stop reach every source") {
|
||||
Rig r;
|
||||
r.src->start();
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ TEST_CASE("A thermal camera needs no settings XML and is always lossless") {
|
|||
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_linux.so");
|
||||
CHECK(tir.library == "libirbgrab_x86_64-linux.so");
|
||||
CHECK(tir.interface == "enp1s0");
|
||||
// [Camera] jxl_distance is for colour cameras; counts default to lossless.
|
||||
CHECK(tir.jxl_distance == 0.0);
|
||||
|
|
@ -367,7 +367,9 @@ TEST_CASE("Thermal camera blocks reject what cannot work") {
|
|||
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_linux.so");
|
||||
"libirbgrab_x86_64-linux.so");
|
||||
// Two cameras, one output folder: their pictures would overwrite each other.
|
||||
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.label", "X"}, {"Camera.tir.label", "X"}})));
|
||||
// A disabled thermal camera needs no serial, like any other.
|
||||
CHECK_NOTHROW(ConfigLoader::fromMap(
|
||||
base({{"Camera.tir.id", ""}, {"Camera.tir.enabled", "false"}})));
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ struct FakeSdk {
|
|||
int connects = 0;
|
||||
// The newest frame: counts, and the camera's number for it.
|
||||
std::vector<uint16_t> px;
|
||||
int64_t image_num = 0;
|
||||
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)
|
||||
|
|
@ -66,7 +67,7 @@ Ret fConnect(Handle, char* 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;
|
||||
if (ok) { ++g->connects; g->lut_calls = 0; }
|
||||
return ok ? kSuccess : kTypeErr | 0x10;
|
||||
}
|
||||
Ret fDisconnect(Handle) { return kSuccess; }
|
||||
|
|
@ -136,14 +137,23 @@ Ret fPtr(Handle mem, Handle* p, int32_t* bytes) {
|
|||
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 = reinterpret_cast<Mem*>(mem)->num;
|
||||
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;
|
||||
}
|
||||
|
|
@ -235,7 +245,7 @@ TEST_CASE("a fresh frame is delivered once, as counts with their scale") {
|
|||
CHECK(px5 == 57315);
|
||||
REQUIRE(f.thermal);
|
||||
CHECK(f.thermal->cal == 0);
|
||||
CHECK(f.thermal->kelvin_per_count == doctest::Approx(0.01)); // measured from the LUT
|
||||
CHECK(f.thermal->kelvin_per_count == doctest::Approx(0.01)); // documented, then measured
|
||||
CHECK(f.thermal->missed_packets == 1);
|
||||
|
||||
t.sdk.push(29400);
|
||||
|
|
|
|||
Loading…
Reference in New Issue