Give the thermal picture the time its own frame arrived

Both frames of a waypoint used to take the colour frame's time, which made
the thermal one look taken at a moment it was not: it is the newest frame the
camera had, up to max_frame_age_ms old. Each file and CamEvent now has the
time its own frame reached the host; the two of one waypoint share hdg/pit and
are at most that age apart. The docs say to pair them by that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Peter Bochmann 2026-10-15 19:42:32 +02:00
parent ff89643a4f
commit fc507f7407
Signed by: peter.bochmann
GPG Key ID: 94E8C523489B8AC1
7 changed files with 19 additions and 20 deletions

View File

@ -100,7 +100,8 @@ frame_timeout_ms = 5000
; --- A thermal camera (InfraTec TarisIR mini), beside the colour one ---------
; It takes its picture at the same waypoints and intervals, right after the
; colour camera, and both carry the same timestamp: RGB/<t>.jxl and TIR/<t>.jxl.
; 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]

View File

@ -140,8 +140,9 @@ With a thermal camera the scheduler and the pipeline still see one
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
`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

View File

@ -1065,9 +1065,9 @@ firmware `ST` lines (per-axis state + encoder counts), parsed by
A TarisIR mini beside the colour camera takes its picture at the same waypoints
and intervals: the colour camera is asked first, and only once it has delivered
is the thermal one asked for its newest frame. Both frames carry the colour
frame's timestamp, so `RGB/<t>.jxl` and `TIR/<t>.jxl` are a pair (and so are
their CamEvents, see [mqtt-api.md](mqtt-api.md#thermal-camera-cam-tir)).
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

View File

@ -153,9 +153,12 @@ their payload is unchanged.
| `tmed` | int/null | Median of the image, tenths of a °C (the anchor for a display window) |
| `hotx`, `hoty` | number/null | Where the hottest pixel is: its centre, 0..1 across and down the image **as stored** (after `rotation_deg`), as for detection boxes |
- **Same `time` as the colour picture.** The thermal camera is asked right after
the colour one, at the same waypoint, and its event and file take the colour
frame's timestamp: `RGB/<time>.jxl` and `TIR/<time>.jxl` are one pair. A
- **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

View File

@ -19,8 +19,9 @@ namespace fgc {
// frame for the same waypoint. The others are best effort - a follower with
// no fresh frame costs that picture its thermal image, nothing more.
//
// Every frame of one trigger carries the leader's timestamp, so the pair is
// RGB/<t>.jxl and TIR/<t>.jxl with the same <t> in both CamEvents.
// 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`,
@ -71,8 +72,6 @@ private:
std::vector<int> counts_; // cameras each child is configured with
std::vector<int> offsets_; // global index of each child's camera 0
FrameCallback callback_;
bool in_trigger_ = false;
long long shot_ts_ = 0; // the leader's frame time, during a trigger
int follower_wait_ms_;
long long waiting_since_ = 0; // steady ms a picture began waiting for a follower
};

View File

@ -76,12 +76,9 @@ bool CompositeCameraSource::trigger() {
<< " ms; taking the picture without it";
}
waiting_since_ = 0;
in_trigger_ = true;
shot_ts_ = 0;
const bool taken = children_[0]->trigger();
if (taken)
for (size_t k = 1; k < children_.size(); ++k) children_[k]->trigger();
in_trigger_ = false;
return taken;
}
@ -100,7 +97,6 @@ void CompositeCameraSource::setFrameCallback(FrameCallback cb) {
// The leader's frames pass through untouched (they can be 24 MB); its
// camera 0 is global camera 0, so not even the index changes.
children_[0]->setFrameCallback([this](const Frame& f) {
if (in_trigger_ && shot_ts_ == 0) shot_ts_ = f.timestamp_ms;
if (callback_) callback_(f);
});
for (size_t k = 1; k < children_.size(); ++k) {
@ -109,7 +105,6 @@ void CompositeCameraSource::setFrameCallback(FrameCallback cb) {
if (!callback_) return;
Frame g = f;
g.cam_id = f.cam_id + offset;
if (in_trigger_ && shot_ts_ != 0) g.timestamp_ms = shot_ts_;
callback_(g);
});
}

View File

@ -100,12 +100,12 @@ TEST_CASE("the composite numbers the cameras leader first, and counts them all")
CHECK(r.frames[2].cam_id == 2); // the thermal camera
}
TEST_CASE("one trigger gives every frame the leader's time") {
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 == 1000); // not its own 5000
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") {