Skip to content

Add lossless raw thermal streaming, recording and SupportProxy transport - #31

Merged
tridge merged 15 commits into
ArduPilot:masterfrom
tridge:pr-raw-thermal-stream
Sep 23, 2026
Merged

tridge merged 15 commits into
ArduPilot:masterfrom
tridge:pr-raw-thermal-stream

Conversation

@tridge

@tridge tridge commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Add lossless raw thermal streaming and recording on MT11 and SITL-MT11, preserving the sensor's complete 640×512 16-bit radiometric output together with capture-time telemetry. This makes temperature measurements and approximate map projection possible without extracting data from a colourised display stream.

The first two commits align camera telemetry timestamps with the vehicle clock and derive ROI line-of-sight rates from position and velocity, so the per-frame telemetry carried by the raw stream refers to capture time. They were previously proposed separately as #30, which is superseded by this PR.

  • Encode independent FFV1 level-3 frames in Matroska, with per-frame timestamps, temperature scale/range, vehicle position and velocity, vehicle/gimbal attitude, source ages and thermal FOV. Preserve native samples without palette conversion, resizing or overlays.
  • Advertise raw thermal as stream 3 while retaining the existing display streams and legacy port-7345 service. Add persistent, live RAW_STREAM_FPS and RAW_RECORD_FPS parameters through MAVLink and the web UI. Recording follows existing manual, automatic and while-armed video policies; both rates default to 5 fps and recording rate 0 disables raw recording.
  • Add PROXY_VID3_PORT and a configurable stream name for publishing through SupportProxy. Proxy requests receive /v3.mkv; a separate publisher keeps only the latest queued frame and tolerates slow uplinks without waiting in the capture/recording path. Proxy streaming also works with the local thermal HTTP listener disabled.
  • Add tools/thermal_to_video.py for lossless conversion of legacy captures and extraction back to individual images. Optional --bin reconstructs per-frame telemetry from an ArduPilot DataFlash log.
  • Document the format, parameters, interoperability requirements, hardware measurements and interrupted-recording behaviour.

Review fixes (one commit per subsystem):

  • mavlink_server: VIDEO_START/STOP_STREAMING for stream 3 toggled the encoder before denying the command when only SupportProxy carried the stream. It is now gated on the same availability stream_count() uses. The SupportProxy SITL raw-thermal case runs without the local listener and checks stream 3 start/stop.
  • mavlink_server: the time-aligned yaw history dropped vehicle telemetry 250 ms after the last sample, and position after 250 ms, where the previous code held them for 1 s and 1.5 s with prediction capped at 250 ms. This flipped attitude feedback between earth and vehicle frames on short gaps and failed the MT11 integration and manual-control tests. Extrapolation is capped at 250 ms on either side of the history, the holds are restored, and status falls back to the current held yaw when feedback cannot be aligned.
  • media: unused parameter in the host stub under -Werror.
  • camera_definition: PROXY_VID3_PORT is no longer advertised in the camera definition, matching the other PROXY_ settings; it remains an INI, environment and MAVLink parameter.
  • Makefile: the static libav archives' platform libraries are taken from the pkg-config files ffmpeg installs, which fixes the Cygwin SITL link against BCrypt.
  • thermal_to_video: RATE rates are logged in deg/s and are body gyro rates, while the schema's yaw_rate_rad_s is the earth-frame Euler yaw rate the camera receives live. The converter now applies the camera's own conversion, (q sin(roll) + r cos(roll)) / cos(pitch), with roll interpolated along the shortest arc from ATT; near vertical pitch the rate is undefined and the snapshot omits it rather than bridging the gap. The series builder is a separate function with unit tests for units, bank, roll wrap and the vertical gap.
  • tests: the routed ArduPilot MAVLink test expected two video streams; the MT11 SITL advertises the raw stream too, so it now checks stream 3's type, URI, flags and geometry.
  • sitl: the SupportProxy test's --mavproxy default is repo-relative (../MAVProxy, or MAVPROXY_REPO through make), and make sitl-supportproxy-test runs the --raw-thermal --reconnect case that covers proxy-only stream 3 control.

Validation completed:

  • Real MT11 streaming, telemetry, concurrent ordinary RTSP streams, reconnects, legacy compatibility and microSD recording. A shared legacy/FFV1 capture matched all 655,360 raw bytes; independent 2 fps streaming and 3 fps recording measured 1.9948 and 2.9791 fps.
  • SITL checked every sample bit, metadata pairing, independent rates, live parameter changes, manual/automatic/armed recording, and recording with streaming stopped or clients slow.
  • End-to-end SupportProxy tests covered discovery, native samples and telemetry, proxy restart, password and session admission, and operation without the camera's local raw HTTP listener. Queue tests covered raw latest-frame retention and existing RTSP recovery.
  • MT11/A8 MAVLink parameter tests, camera configuration tests, web integration/browser tests and proxy regression tests passed. MT11 firmware/web, MT11/A8 SITL and SupportProxy builds passed.
  • After the review fixes: telemetry-time, MAVLink protocol, camera-definition and converter unit tests; the MT11 MAVLink integration, manual-control and six component-pair tests; the routed ArduPilot MAVLink test over TCP and UDP against a local ArduCopter SITL; the three ROI motion SITL runs; and the SupportProxy raw-thermal, video-only, session and single-video cases.
  • The 16,630-file legacy capture set round-tripped byte-identically: 10,898,636,800 raw bytes to 2,726,112,687 Matroska bytes, approximately 4:1 compression. Truncating an actual MT11 recording inside a later frame left earlier complete frames decodable.

The MAVProxy raw thermal reader needs ArduPilot/MAVProxy#1760, which disables FFmpeg frame-rate probing: with no DefaultDuration in the live stream, probing read up to 64 KB of frames inside the 3 s open timeout, and highly compressible frames (SITL test pattern, uniform scenes at low rates) made an interrupted probe poison the demuxer. With that change sitl/test_raw_thermal_stream.py passes end to end.

The proxy path requires the companion SupportProxy Matroska changes. Temperature-aware viewing requires the MAVProxy raw thermal viewer or the companion desktop viewer; the proxy's existing MPEG-TS browser player cannot decode this stream.

Interoperability remains experimental: MAVLink stream type 200 and the APCG BlockAdditional mapping are private values, not upstream allocations. Timestamps have microsecond representation but describe USB reception rather than calibrated sensor exposure. Sustained 25 fps hardware operation and surveyed projection accuracy have not been validated. Truncation tolerance does not guarantee SD/filesystem persistence after power loss; the camera currently forces a flush when recording stops.

Outstanding review follow-ups: raw frame ingest runs at sensor rate even with no consumer, an encoder failure leaves stream 3 advertised, a flush error when stopping raw recording rolls back the rate while the file is already closed, plus isolating raw startup/storage failures from normal video and unrelated configuration changes, SD-write stalls, raw-file rollover/failure reporting, encoder recovery, and converter output on filesystems without hard links.

Map vehicle boot clocks into local time and match vehicle yaw to gimbal feedback so transport jitter does not mix samples of different ages. Derive stationary ROI line-of-sight rates directly from position and velocity, avoiding quantisation from finite differences of rounded coordinates, and stop prediction when telemetry is stale.
Preserve yaw history when corrected sample times coincide, allow ATTITUDE fallback at prediction expiry, and retain vertical-pitch metadata without using an undefined yaw rate for control. Carry measured gimbal yaw rate into video metadata so prediction avoids differentiating quantised angles, with regression tests and documentation for both changes.
Preserve native 16-bit thermal samples and capture-time telemetry in FFV1/Matroska on MT11 and SITL. Expose independent live streaming and recording rates through MAVLink and web parameters, with raw recording following the existing video recording policy.

Add legacy-frame conversion and extraction tools, interoperability tests, and hardware validation results.
Old raw thermal .bin captures carry only a timestamp, so their per-frame
apcg.telemetry.v1 snapshot was null. Add --bin FLIGHT.bin to fill it from
an ArduPilot dataflash log: GPS week/ms give the log absolute UTC, and
each frame's absolute filename (or mtime) time is matched against it.
Position, NED velocity, vehicle attitude with yaw rate and gimbal
attitude are interpolated between the bracketing samples, yaw along the
shortest arc, with a per-field age. Frames outside the log's coverage
keep a reconstructed clock and a null pose.

Gimbal backends fill MNT differently, so per axis the reported angle is
preferred, then the demanded angle, and vehicle-relative yaw is preferred
over earth-referenced yaw (converted with the vehicle yaw); the chosen
source is recorded in each frame's gimbal_pose_source. Adds sampler and
gimbal-selection unit tests.
Expose the thermal proxy port and stream name through camera configuration and advertise the proxy Matroska URL over MAVLink. Publish native FFV1 frames and capture metadata through an independent worker that retains only the newest queued frame and tolerates slow uplinks without blocking local recording.

Add configuration, queue and end-to-end SITL coverage, including proxy restart and operation with the local thermal HTTP listener disabled.
@AP-Review

AP-Review commented Sep 21, 2026 •

Copy link
Copy Markdown

Deprecated — see below for the updated review.

Previous review (2026-09-21)

Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting.

Full report: https://uav.tridgell.net/DevCallReviews/2026_09_22_AIReview/devcall_pr_reviews.html#prAP_CameraGimbal-31

Reviewed at head b784b6279f.

REQUEST CHANGES. The FFV1/Matroska pipeline is genuinely lossless and is the strongest part of this change — I verified that end to end. What blocks it is more prosaic: four CI jobs are red and three are caused directly by this PR, each traced to its actual failure line in the job log. There's also one wire-protocol bug that two independent passes found separately.

Given this repo merged #24 thirty-eight minutes before its review landed, please hold this one until at least findings 1-4 are addressed. It's open and unmerged as I write.

1. BUG — camera_app/src/protocol/mavlink_server.cpp:1567 — the stream-3 enable is applied before the command is decided, and the command is then denied. The range guard at :1564 admits params[0] == 3 because stream_count() is APCAM_NUM_STREAMS + (ca_thermal_stream_available() ? 1 : 0) = 3. :1567 then runs ca_thermal_stream_enable(enabled) — state changed. :1571 is false because ca_thermal_stream_port() is 0, :1572 is false because 3 > 2, and :1573 returns MAV_RESULT_DENIED. The GCS is told the command failed while the stream has in fact been started or stopped.

This is reachable, and the decisive line is thermal_stream.cpp:370: available = port || s->proxy; — so with CAMERA_APP_RAW_THERMAL_PORT=0 and SupportProxy publishing on (the configuration RAW_THERMAL.md documents), available is true while public_port is 0. Fix — gate on the same predicate stream_count() uses, and move the enable into the accepted branch:

if (stream == 0U) {
    ca_thermal_stream_enable(enabled);
    for (unsigned i = 0; i < APCAM_NUM_STREAMS; i++) server->stream_enabled[i] = enabled;
}
else if (stream == CA_RAW_THERMAL_STREAM_ID && ca_thermal_stream_available())
    ca_thermal_stream_enable(enabled);
else if (stream <= APCAM_NUM_STREAMS) server->stream_enabled[stream - 1U] = enabled;
else return MAV_RESULT_DENIED;

2. BUG — camera_app/src/media/stub.cpp:120 — this is the exposure job failure. The non-CAMERA_APP_SITL arm of configure_raw_thermal() is return 0;, leaving settings unused. The camera-app-host target (Makefile:316) compiles with -Wall -Wextra -Werror and without the -Wno-error=unused-parameter the cross targets carry, so the log reads verbatim: src/media/stub.cpp:120:48: error: unused parameter 'settings' [-Werror=unused-parameter]. Fix: (void)settings; in the #else arm.

3. BUG — camera_app/src/protocol/camera_definition.cpp:47 — this is the release job failure. The new CONFIG("PROXY_VID3_PORT", …) entry violates tests/test_camera_definition.py:59, which asserts assertNotIn('PROXY_', name) — SupportProxy settings are deliberately not advertised to the GCS. The log reads AssertionError: 'PROXY_' unexpectedly found in 'PROXY_VID3_PORT'. Either drop it from the camera definition (it stays reachable via the INI key and the env/param path at APC_Config.h:271-273) or change the test and say why in the commit message. The other two new names are fine — RAW_STREAM_FPS and RAW_RECORD_FPS are 14 chars, PROXY_VID3_PORT 15.

4. BUG — camera_app/Makefile:457 — this is the Cygwin build failure. The link rule hardcodes -lpthread -lm, which doesn't carry static libavutil.a's platform dependencies; on Cygwin random_seed.o pulls in BCrypt and the log shows three undefined reference to 'BCrypt…' then collect2: error: ld returned 1 exit status. Since tools/build_thermal_codecs.sh:34-45 already runs a real ffmpeg configure, the robust fix is to consume what ffmpeg reports — read Libs.private from the installed libavutil.pc (or EXTRALIBS from ffbuild/config.mak) into a THERMAL_EXTRA_LIBS and append it to both link rules. Hardcoding -lbcrypt under a uname -s test works too but will break at the next ffmpeg dependency.

5. BUG — tools/thermal_to_video.py:249 — the reconstructed yaw rate is written in degrees per second into a field declared as radians per second, a factor of 57.3 in every converted file. Checked against ArduPilot rather than argued: the RATE message's unit string is "skk-kk-kk-oo--" (AP_Logger/LogStructure.h:202), whose code for field Y is 'k', and LogStructure.h:52 defines { 'k', "deg/s" }. The strongest evidence is in your own file — the three lines above apply rad = math.radians to ATT's Roll/Pitch/Yaw, so the conversion is plainly understood and was just omitted here. Fix: 'yaw_rate_rad_s': lambda r: rad(r.Y). A logged 90 deg/s currently emits 90.0; correct is 1.5707963.

6. ISSUE — thermal_stream.cpp:73 — the frame ingest runs at full sensor rate with no consumer check, and with the shipped defaults all of it is discarded. ca_thermal_stream_publish() runs on the MT11 sensor callback thread at 25 Hz, and the only guard is the null check at :76. Per frame: a full-frame min/max scan over 640×512 = 327,680 pixels, a metadata snapshot plus two snprintfs of ~1.5 KB of JSON, and a 655,360-byte memcpy under s->lock — 8.19 M pixel reads/s and 16.4 MB/s of memcpy, sustained. With camera.ini:91 video3_port = 0, no HTTP client and no recording, the worker's gate at :272-282 returns early and never reads s->pixels. Even while recording at the default RAW_RECORD_FPS=5, four frames in five are wasted. This also contradicts RAW_THERMAL.md ("Encoding runs only when recording or a ready streaming client needs a frame") — the encode is gated, the ingest isn't. Suggest the worker publish an atomic wanted flag and publish() early-out on it; cost is one worker tick plus one sensor period of staleness on the first frame after a client connects.

7. ISSUE — thermal_stream.cpp:300 — after an encoder failure the stream stays advertised with nothing behind it. The failure path breaks out of thermal_worker permanently, but available stays true (only cleared in ca_thermal_stream_close) and the listener stays open, so VIDEO_STREAM_INFORMATION keeps advertising stream 3 with a live URI, a later VIDEO_START_STREAMING sets enabled=true again and restores RUNNING, and new clients sit in the backlog forever — the 2 s progress timeout lives in the dead worker. Suggest setting available=false and shutting down the listener on that path.

8. ISSUE — thermal_stream.cpp:194 — a flush error when stopping raw recording leaves it silently dead for the rest of the session. close_recording() sets record_fd = -1 and bumps record_generation unconditionally before it can return -1, so on a flush failure the file really is closed but record_fps is rolled back and s->recording stays true; APC_Media::configure then rolls back too, so a later configure with the same value finds previous != s->record_fps false at :200 and does nothing.

Notes (detail in the report): 640×512 is hardcoded in five files while CA_MT11_THERMAL_WIDTH/_HEIGHT already exist, and publish() takes a bare pointer with no length — not exploitable today (both callers traced) but one sensor variant from an overflow; the byte-exact strcmp on "HTTP/1.1 100 Continue\r\n\r\n" reduces every interim-response variation to "Permission denied"; and the raw listener binds INADDR_ANY with no auth, leaking full-rate radiometric pixels and the whole apcg.telemetry.v1 snapshot (lat/lon/alt, NED velocity, attitudes) to anyone who can reach the camera — consistent with the existing RTSP posture, but it leaks strictly more than the display streams do.

The fourth red job, mt11-sitl, times out in wait_attitude. This PR carries two commits that rewrite the attitude/yaw timebase (a5af6fe, 4b96c28), so it's plausibly related rather than flaky — but I could not confirm attribution without a master-baseline run and am not claiming it. Those two commits are also a telemetry subsystem with no thermal content; splitting them out would stop the thermal work being blocked by an unrelated regression.

What I checked and cleared: the lossless claim holds end to end — FFV1 level 3, GRAY16LE, gop_size=1, the 16-bit repack writes full little-endian with no 8-bit intermediate anywhere, the declared temperature_scale_k: 0.015625 matches the Celsius conversion, and thermal_codec_bench.cpp:53-58 does a real per-pixel encode→decode bit-exactness check over 100 frames. The Matroska muxer was hand-decoded and is correct, including the reserved all-ones VINT avoidance and the unknown-size Segment. Untrusted wire input is bounded (fixed 1024 buffer, 4096 cap, exact-prefix URI match, 2 s timeout; no client value used as an index or size). Frame-size arithmetic has no overflow. Backpressure is properly designed. The cross-repo wire contract with ArduPilot/SupportProxy#45 was decoded from both sides and agrees exactly.

…s accepted

VIDEO_START/STOP_STREAMING for stream 3 toggled the encoder before the
command was checked, then returned MAV_RESULT_DENIED when the local raw
HTTP listener was disabled. With SupportProxy as the only transport the
stream changed state while the GCS was told the command failed.

Gate on the same availability stream_count() already uses, so proxy-only
publishing accepts the command. The SupportProxy SITL raw thermal case
now runs without the local listener and checks stream 3 start/stop.
Load the checkout's ThermalReader by path there, since the shared test
helpers have already imported the installed MAVProxy package.
The camera-app-host build uses -Werror=unused-parameter, which failed
the non-SITL arm of configure_raw_thermal().
SupportProxy settings are deliberately kept out of the camera definition,
matching the other PROXY_ keys; the setting remains available through the
INI file, environment and MAVLink parameters.
The link rules hardcoded -lpthread -lm, which misses libavutil's platform
dependencies; on Cygwin random_seed.o needs BCrypt and the SITL link
failed. Take the extra libraries from the pkg-config files ffmpeg's
configure installed so new dependencies follow automatically.
ArduPilot logs RATE.Y with unit 'k' (deg/s) but it was written unchanged
into the rad/s telemetry field. Split the series builder out of the log
loader so the unit conversions can be tested without a dataflash file.
The time-aligned yaw history rejected any query more than 250 ms past
its newest sample, and the vehicle position expired after 250 ms, where
the previous code held both for 1 s and 1.5 s with prediction capped at
250 ms. A short telemetry gap flipped GIMBAL_DEVICE_ATTITUDE_STATUS
between earth and vehicle frames and stopped ROI tracking; the MT11
integration and manual-control tests, which send vehicle state once,
timed out in CI.

Cap extrapolation at 250 ms on either side of the history and hold the
newest sample for 1 s, restore the 1.5 s position hold, and fall back to
the current held yaw when gimbal feedback cannot be aligned with the
history.
@AP-Review

Copy link
Copy Markdown

Automated review note — AI-generated (Claude), cross-checked by a second independent Claude pass against the live diff. Please sanity-check before acting.

Full report: https://uav.tridgell.net/DevCallReviews/followups/2026_09_22_2315/devcall_pr_reviews.html#prAP_CameraGimbal-31

Re-reviewed at head 1ea0f69d02 (previously commented at b784b6279f); my earlier comment above is superseded.

All five findings from last round are genuinely fixed, and three of the four red CI jobs are now green. Verdict stays REQUEST CHANGES for one reason: mt11-sitl is still red, the failure this time is caused by this PR, and it is a one-line expectation in a test the PR does not update. Both passes found it independently.

Previous round

  • Stream 3 enabled before the denial path — RESOLVED. camera_app/src/protocol/mavlink_server.cpp:1572-1580: the ca_thermal_stream_enable() call is now inside the accepted branch and the else return MAV_RESULT_DENIED is unreachable for stream 3. Traced end to end: stream_count() (:835-837) is APCAM_NUM_STREAMS + available, APCAM_NUM_STREAMS is 2 on all four targets (include/apcam/target_{mt11,a8,z1mini,zr10}.h:11) and CA_RAW_THERMAL_STREAM_ID is 3, so params[0]==3 is admitted at :1569 only when available, and then always ACCEPTED.
  • Unused settings in the non-SITL arm of stub.cpp — RESOLVED; exposure is green.
  • PROXY_VID3_PORT in the camera definition — RESOLVED. camera_definition.cpp:47-48 now carries only RAW_STREAM_FPS / RAW_RECORD_FPS, and I checked both survivors are real config params whose declared ranges match the validator exactly (web/mt11-web.cpp:631-634 declares 1–25 and 0–25; thermal_stream.cpp:189-190/345-346 rejects outside 1–25 and >25), so there is no GCS-settable value the camera will refuse. The proxy port stays reachable via video3_port (support_video.cpp:370, mavlink_server.cpp:891-893), and the definition version is a CRC of the XML, so dropping the entry cannot leave a stale GCS cache. release is green.
  • Makefile hardcoded FFmpeg link flags — RESOLVED using the suggested approach: THERMAL_{TARGET,HOST}_EXTRA_LIBS are derived from Libs/Libs.private in the installed libavcodec.pc / libavutil.pc. The recursive = means it expands at recipe time, i.e. after build_thermal_codecs.sh has installed the .pc files, so the ordering is right, and -L${libdir} is filtered rather than expanded. Reproduced against a representative static-ffmpeg .pc pair: it correctly picks up -pthread -lm -latomic -lbcrypt. Cygwin build is green. One wrinkle: the 2>/dev/null means a missing or relocated lib/pkgconfig silently expands to empty rather than erroring.
  • thermal_to_video.py yaw-rate units — RESOLVED (units). tools/thermal_to_video.py:254 is now lambda r: rad(r.Y), with test_dataflash_degrees_become_radians asserting π/2 for a logged 90. The unit was re-derived from source rather than taken on trust: libraries/AC_AttitudeControl/LogStructure.h:202 gives "skk-kk-kk-oo--", 'k' for field Y. The new test passes locally. But the frame is still wrong — see below.
  • mt11-sitl attribution — last round's caution was right, and it is now settled: the previous failure was camera_app/tests/test_mavlink_integration.py:203 wait_attitude → TimeoutError, and 1ea0f69d0 fixes it; that step now prints PASS.

A finding that was queued and then dropped, because the author is right. I was going to report that the new ca_yaw_history::at() hold branch returns a stale yaw rate during the hold window. It does not regress anything: master's current_vehicle_attitude (mavlink_server.cpp:1044-1062) already held for VEHICLE_ATTITUDE_TIMEOUT_MS = 1000 ms with extrapolation capped at 250 ms (fminf(elapsed, VEHICLE_PREDICTION_MS*0.001f)) and returned the un-decayed vehicle_yaw_rate_rad_s. telemetry_time.h:100-106 reproduces exactly that, and VEHICLE_POSITION_TIMEOUT_MS goes back to 1500. I built a probe against the real header to confirm the hold shape (at(last+1000) → yaw frozen at last.yaw + rate*0.25 with rate = last rate; at(last+1001) → false). The earlier commits had narrowed both windows to 250 ms, which is what broke wait_attitude — so 1ea0f69d0 is a faithful restoration, not a hack. Withdrawn; you would otherwise have spent time on it.

🔴 BLOCKER — tests/test_ardupilot_mavlink.py:642 still asserts two streams, and that is why mt11-sitl is red

assert set(streams) == {1, 2}, and request_video_stream_information (:178-196) sends MAV_CMD_REQUEST_MESSAGE with stream_id 0, i.e. all streams. The PR's SITL build defines CA_THERMAL_STREAM_FFV1 and ca_thermal_stream_open defaults the listener port to rtsp_port + 2 (camera_app/src/streaming/thermal_stream.cpp:347), so with the SITL RTSP port 8554 the listener opens on 8556 — the job's own camera.log says so verbatim: camera-app: lossless thermal ready: HTTP port=8556 FFV1 gray16 640x512 5 fps (test pattern). That sets available = true (:370), stream_count() returns 3, and the assertion fails on the first (tcp) transport.

The PR does not touch this file (git diff ghmaster...pr31 -- tests/test_ardupilot_mavlink.py is empty, and so is the corresponding git log), so the failure was latent last round — the job died earlier, in test_mavlink_integration.py, before ever reaching it. Everything in that suite after line 642 is therefore still unvalidated.

Update the expectation rather than suppressing the stream: assert stream 3 when the raw stream is available, and check its URI and flags (the URI is built at mavlink_server.cpp:886 as http://%s:%u/thermal.mkv), mirroring what sitl/test_raw_thermal_stream.py already does. Setting CAMERA_APP_RAW_THERMAL_PORT=0 in that test's environment would also make it pass, but then the test stops covering the new stream at all. The stream-name and flag constants should be lifted from send_stream_information rather than written from scratch, so no snippet here on purpose.

🟠 sitl/test_support_proxy.py:279 ships a hardcoded personal checkout path as a default

parser.add_argument('--mavproxy', type=Path, default=Path('/home/tridge/project/UAV/MAVProxy.wt/mavcamera'))

Every neighbouring default is repo-relative (REPO / 'build/sitl', REPO.parent / 'SupportProxy'), and this is the only absolute path of its kind in the tree outside packaging/ — so --raw-thermal cannot run for anyone else without the flag. REPO.parent / 'MAVProxy' matches the SupportProxy convention. (Introduced at b784b62, so it is not new in this delta, but it is in a file this delta touches and it was missed last round.)

🟠 tools/thermal_to_video.py:254 — the units fix is right, the frame is not

RATE.Y is a body-frame gyro rate; yaw_rate_rad_s is an earth-frame Euler yaw rate. Traced on both sides:

  • The camera's canonical value comes from AUTOPILOT_STATE_FOR_GIMBAL_DEVICE.angular_velocity_z, which ArduPilot fills with AP::ahrs().get_yaw_rate_earth() (libraries/GCS_MAVLink/GCS_Common.cpp:6483-6497); the fallback feed_forward_angular_velocity_z is rate_ef_targets.z, also earth frame.
  • The camera's own ATTITUDE path converts body → Euler explicitly at mavlink_server.cpp:1729-1732: (pitchspeed*sin(roll) + yawspeed*cos(roll))/cos(pitch), and telemetry_time.h:67 documents "Rates are Euler yaw rates".
  • But RATE.Y is written as yaw : gyro_rate.z where gyro_rate = _rate_gyro_rads * RAD_TO_DEG (libraries/AC_AttitudeControl/AC_AttitudeControl_Logging.cpp:31-45) — the raw body gyro z.

For level pitch the two differ by exactly cos(bank). Reproduced numerically for a coordinated level turn at ψ̇ = 0.5 rad/s: bank 0° → 0.500 (ratio 1.000), 30° → 0.433 (0.866), 45° → 0.354 (0.707), 60° → 0.250 (0.500), while the camera's own body→Euler formula recovers 0.5000 at every bank. Multirotors rarely bank hard and this only affects offline thermal_to_video metadata, hence ISSUE rather than BUG — but it is the same class of error as the 57.3× one, and test_dataflash_degrees_become_radians pins only the unit, not the frame.

Fix: apply the formula the camera already uses, interpolating ATT.Roll/ATT.Pitch onto the RATE timestamps — psi_dot = (radians(RATE.P)*sin(roll) + radians(RATE.Y)*cos(roll)) / cos(pitch). RATE.P and ATT.Roll/Pitch are already loaded in the same builder. No ready-to-paste lambda, because it needs a time-alignment step the current series() helper does not provide. If that is more machinery than it is worth, at minimum say in the docstring at :234 that vehicle_rate is reconstructed as a body rate and only equals the live value in near-level flight.

Still open from last round — re-raised, unchanged

camera_app/src/streaming/thermal_stream.cpp and raw_thermal_server.cpp are byte-identical between b784b6279f and 1ea0f69d02, so nothing here moved:

  • Ungated full-rate ingest in ca_thermal_stream_publish().
  • The stream stays advertised after an encoder failure — the failure path around thermal_stream.cpp:300 sets enabled=false and breaks, but available is only cleared in ca_thermal_stream_close (:377), and mavlink_server.cpp:1578 sets enabled=true again on the next VIDEO_START_STREAMING.
  • A flush error in close_recording() leaves raw recording silently dead.
  • 640×512 hardcoded; bare-pointer publish(); byte-exact strcmp on "HTTP/1.1 100 Continue\r\n\r\n"; INADDR_ANY with no auth.

Notes

  • The new regression test for last round's finding 1 is not run by anything. sitl/test_support_proxy.py:224-228 adds exactly the right check — stop/start stream 3 with CAMERA_APP_RAW_THERMAL_PORT=0, assert MAV_RESULT_ACCEPTED — but --raw-thermal appears only as a manual command in sitl/README.md:558; the sitl-supportproxy-test target (Makefile:237-243) runs six other cases and not this one, and no workflow invokes test_support_proxy.py at all. Understandable given it needs an external SupportProxy checkout, but the fix has no automated guard. The assertions themselves are coherent: eng connects to the proxy's engineering port, so route->kind == ROUTE_PROXY and reachable at mavlink_server.cpp:932-934 is true via support.video3_port, and the status-then-ack ordering matches :1581-1582.
  • A local (non-proxy) client with CAMERA_APP_RAW_THERMAL_PORT=0 now gets MAV_RESULT_ACCEPTED for a stream whose advertised URI is empty and whose RUNNING flag is always cleared (mavlink_server.cpp:1578, send_stream_information:887, send_stream_status:932-934). This reads as intentional per the PR body; flagged only so the asymmetry is a choice rather than an oversight.
  • mavlink_server.cpp:1651 updates the freshness stamp even when the sample is rejected — save_vehicle_attitude sets vehicle_attitude_updated_ms and primary_attitude_ms unconditionally, but ca_yaw_history::add() (telemetry_time.h:85) drops the sample when ms < last.ms. I could not construct an input that produces the backwards step, so this is not a claim that it happens — only that the two pieces of state can disagree by construction. Cheap hardening: have add() report whether it stored the sample. Not new in this round.
  • telemetry_time.h:109 — the comment says "Feedback captured just before the first sample after a reset", but the branch fires for any query older than the whole history, including a full un-reset 128-sample ring. Code is fine; comment understates its scope.
  • Checked and not raised: at() never writes its out-params on any false path, so the new || fallback in pack_gimbal_status cannot read uninitialised floats; the new tests pin real boundaries (250 ms backwards at 839/1090, hold at 3100/3101) rather than restating the implementation; camera_app/README.md and sitl/README.md prose matches the implemented 250 ms / 1 s / 1.5 s behaviour.

CI at 1ea0f69d02

job now last round
build (Cygwin) pass fail → fixed
exposure pass fail → fixed
release pass fail → fixed
legacy-python (3.10.11 / 3.10.13 / 3.12) pass pass
mt11-sitl fail fail → partly fixed

mt11-sitl per step, comparing job 106304530396 (old head) with 106674548488 (new): "Test ArduPilot SIYI drivers against MT11 SITL" passed both rounds; "Test ArduPilot MAVLink drivers over NET against MT11 SITL" no longer dies at test_mavlink_integration.py:203 — that step now prints PASS native MAVLink camera/gimbal capabilities over TCP, UDP and UART with position targeting enabled — and instead fails further on, at the stream assertion above.

What was not checked

The mt11-sitl job end to end (it needs an ArduCopter SITL build plus the pinned camera dependencies; both job logs were read instead, and the attribution above is from the job's own camera.log plus the source path, not a local run). sitl/test_support_proxy.py --raw-thermal was reviewed statically but not executed — it needs a SupportProxy checkout with Matroska relay support. camera_app/tests/test_telemetry_time was built and run and passes; 7 of 11 tests/test_thermal_to_video.py tests pass locally, the other 4 erroring on a missing local av/numpy (an environment gap, and they are green in release). The parts cleared last round — FFV1/Matroska losslessness, wire-input bounds, the SupportProxy wire contract — were not re-audited, since thermal_stream.cpp is byte-identical.

This run's usual second reviewer (Codex) was unavailable — hard-blocked on a usage limit until 2026-09-27 — so the cross-check was a second independent Claude context rather than a different vendor. Both passes reached the same blocker and the same frame error independently.

…test

The MT11 SITL advertises stream 3 whenever its raw listener is open,
so the routed VIDEO_STREAM_INFORMATION check now expects three streams
and verifies the raw stream's type, URI, flags and geometry.
RATE logs body gyro rates, while yaw_rate_rad_s is the earth-frame
Euler yaw rate the camera receives from AUTOPILOT_STATE_FOR_GIMBAL_DEVICE.
Apply the same conversion the camera uses for ATTITUDE, with roll and
pitch interpolated from ATT onto the RATE timestamps, and drop samples
near vertical pitch or outside the attitude coverage.
…AVProxy

The --mavproxy default was a personal checkout path; use ../MAVProxy
like the SupportProxy default and let MAVPROXY_REPO override it. Add the
--raw-thermal --reconnect case to sitl-supportproxy-test so the
proxy-only stream 3 start/stop regression is exercised by the target.
It applies to any query older than the whole history, not only to
feedback captured just after a reset.
@tridge
tridge merged commit fc706a0 into ArduPilot:master Sep 23, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants