Fix C++ client request wedge (uninitialized flag), depth packed as meters, and wall-clock ROS stamps - #177
Conversation
Sensor and pose handlers previously stamped ROS headers with the bridge's wall clock at receive time, discarding the sim-clock time_stamp every Project AirSim message carries. That broke exact RGB/depth/pose sync downstream (ApproximateTimeSynchronizer saw network jitter) and made header stamps inconsistent with the /clock topic. Prefer the message's time_stamp (sim nanos) when present; fall back to wall clock. TF broadcasts reuse the owning message's stamp. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…queued requests TAsyncResultProviderBase's constructor initializer list skipped fis_canceled_, leaving it uninitialized heap memory. Both client worker threads consult FIsCanceled(): when the garbage read true, the sending thread silently skipped sending the request and the receiving thread popped the response entry without ever calling SetDone — so the caller's Wait() blocked forever and, with it, every later request (the ROS2 C++ bridge's clock/services wedged permanently, nondeterministically by heap state). Found via gdb thread dump against a live sim; the Python client was unaffected, which localized the fault. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed meters The depth materials write METERS to the R channel, but the packing cast that value straight to uint16 while labeling the encoding 16UC1-mm — delivering depth quantized to 1 m steps (verified: a rig 2 m above ground read raw median 3). Convert to millimeters before the cast, and saturate at the 65535 ceiling: sky/no-hit pixels are huge or inf and the bare cast wrapped them into phantom finite depths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| // quantization. Saturate at the uint16 ceiling (65.535 m) — sky / | ||
| // no-hit pixels are huge (or inf) and the bare cast wrapped them | ||
| // around into phantom finite depths. | ||
| float DepthMilli = SrcPixel.R.GetFloat() * 1000.0f; |
There was a problem hiding this comment.
We must indicate that the output is in meters, not millimeters. Multiplying by 1000 is unnecessary and adds error.
There was a problem hiding this comment.
Thanks! Hm this is what Claude Fable said, thoughts?
I'd like to push back gently on this one. The R channel arrives as fp16 meters, so its precision floor is already ~4 mm at 4–8 m and ~31 mm at 32–64 m — the ×1000 multiply happens in float32 and adds at most 1 ulp on top of that, while the uint16-mm representation quantizes at 1 mm, i.e. below the fp16 floor. Keeping raw meters in a uint16 instead quantizes depth to whole meters (before this patch, a camera 3.24 m from a wall read raw 3), which makes the stream unusable for RGB-D consumers. Millimeters is also what this function already documents (// ...depth in mm, DepthMilli) and matches the standard ROS 16UC1 depth convention. If the concern is the 65.535 m range cap for long-range aerial use, I'm happy to make the scale configurable per capture (defaulting to mm), or to implement pixels-as-float for true float32 meters — let me know which you'd prefer.
There was a problem hiding this comment.
Also:
And 16UC1 depth in millimeters is the established ROS/OpenNI/RealSense convention; relabeling ProjectAirSim's 16UC1 as integer meters would be a surprising contract for every downstream ROS consumer.
There was a problem hiding this comment.
Currently there's no float32 computation in the DepthMili variable. It's all with uint16.
Regarding the second comment: It's an interesting observation, but the most performant solution would be to compute that at a later stage.
There was a problem hiding this comment.
Sorry for forwarding these responses through Claude! I'm still familiarizing with the code base, just trying to contribute what helped me resolve a bug during my usage. From what I understand though this seems to make sense. I think the main issue if we use meters here is the loss of precision for anything sub-meter? Please let me know if I'm misunderstanding!
Thanks for taking a look! Small clarification on the code: On "there's no float32 computation in DepthMilli, it's all uint16" — that's just not what the code says, in either version. The original line is:
float DepthMilli = SrcPixel.R.GetFloat(); // float32, from the fp16 texel uint16 DepthUint16 = static_cast<uint16>(DepthMilli);DepthMilli is declared float, and FFloat16::GetFloat() returns float32. The value lives as float32 right up to the final cast — in both the original and the patch. All the patch adds is one float32 multiply before that cast. So the premise that the multiply somehow happens "in uint16" (and would therefore add integer error) is a misreading; the multiply is exact to 1 ulp in float32, then a single quantization to uint16 happens either way. The only question is what one uint16 step means — 1 mm (patch) or 1 m (original).
For second comment:
Ah, I see — agreed that core_sim shouldn't adopt conventions because they're ROS conventions, and the ROS bridge is the right place for ROS-facing conversion. I only cited
16UC1-mm as corroboration; the actual reason for the ×1000 is internal to the sim: the wire is uint16, so a later stage can only convert what survives the cast, and integer meters destroy sub-meter precision at packing time (3.0 m and 3.9 m both arrive as3— nothing left for the bridge to compute). Any sub-meter scale works; mm matches this function's existing// ...depth in mmcomment andDepthMillinaming. If the 65.535 m cap is a concern for long-range scenes, I'm happy to switch to centimeters (655 m range) or add a per-capture depth-scale setting instead — whichever you prefer.
There was a problem hiding this comment.
The depth milli is ultimately transmitted in 16 bits, so multiplying by 1000 in float16 would introduce a very small error; the biggest problem with float16 is overflow. There are different ways to tackle this, but for now, for ROS, what you can do is multiply the depth image you receive by 1000.
There was a problem hiding this comment.
The float32 should be converted to float16 or some other type that allows for a reduction in the amount of processing and copying in this step.
There was a problem hiding this comment.
Ah okay! I see now, that makes sense. Implemented the change with some caveats:
from Claude:
Implemented in 37bdd2f: the depth wire now carries each pixel's render-target FFloat16 bit pattern verbatim (little-endian, new encoding label
16FC1, meters). The packing loop is now a pure bit copy — cheaper than both the old cast and the mm multiply — with no value conversion, no added quantization beyond the fp16 render target itself, no 65 m cap, and sky/no-hit arriving as+inf. Downstream decoders are updated in the same commit: the ROS2 C++ bridge decodes16FC1→ standard ROS32FC1float meters (non-finite → NaN), the legacy Python rosbridge gets an equivalent converter, and the Python client'sunpack_imagereturns a float16 array (the16UC1branches are kept for compatibility with older sims).One small correction on the interim workaround suggested earlier ("for ROS, multiply the depth image you receive by 1000"): that couldn't work against the previous code —
static_cast<uint16>(meters)is a value truncation, not a bit copy, so the wire carried whole meters and the sub-meter information was already destroyed before any later stage could scale it (a wall at 3.24 m arrived as exactly3). With this commit the wire is the fp16 bits themselves, so downstream conversion is now lossless — verified end-to-end on our rig: the decoded32FC1stream shows fractional-millimeter depth structure and far geometry at ~4 km, both impossible under either previous packing.Note this changes the depth wire encoding label from
16UC1to16FC1intentionally, so older clients fail loudly (unknown encoding) rather than silently misreading fp16 bits as integers.
jonyMarino
left a comment
There was a problem hiding this comment.
Thanks for your contribution! Please review the comments.
…de downstream Review rework of the previous millimeter-packing commit, per maintainer feedback on iamaisim#177: the sim should ship depth in meters with scale conversion done at a later stage. The previous code could not support that — static_cast<uint16>(meters) truncated depth to whole meters at packing time, so no later stage could recover sub-meter structure. The wire now carries each pixel's render-target FFloat16 bit pattern verbatim (little-endian, new encoding label 16FC1): zero value conversion in the packing loop (cheaper than both the old cast and the mm multiply), no added quantization beyond the fp16 render target itself, no 65 m range cap, and sky/no-hit pixels arrive as +inf. Downstream decoders updated: - ROS2 C++ bridge: 16FC1 -> standard ROS 32FC1 float meters, non-finite -> NaN (16UC1 branch kept for older sims). - Legacy Python rosbridge: same, via convert_image_16fc1_to_ros. - Python client unpack_image: float16 numpy view; example scripts accept the new encoding for depth (.pfm save path). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
About
Three fixes found while integrating the ROS2 C++ bridge into a mapping pipeline on Linux (Blocks, UE 5.7.4):
cpp client: initialize fis_canceled_—TAsyncResultProviderBase's constructor initializer list skipsfis_canceled_, leaving it uninitialized memory. Both client worker threads consultFIsCanceled(): when the garbage reads true,RequestSendingThreadProcsilently skips sending the request andResponseReceivingThreadProcpops the pending-response entry without ever callingSetDone, so the caller'sWait()blocks forever — and every later request queues behind it. In practice this permanently wedges the C++ client's whole request channel (the ROS2 bridge's/clocknever publishes,move_*/SetPoseservice calls hang), nondeterministically depending on heap state — which is why it can appear to work on one run and deadlock on the next. One-line fix.unreal: pack depth as actual millimeters with saturation— the depth materials write meters to the R channel, butFImagePackingAsyncTaskcasts that float straight touint16while the wire encoding is declared as16UC1depth in mm. Consumers therefore receive depth quantized to whole meters (a camera 2 m above ground reads raw2). Sky / no-hit pixels (huge or inf) also wrap around in the bare cast into phantom finite depths. Fixed by converting to millimeters and saturating at 65535, which downstream can treat as "no return".ros2 cpp bridge: stamp headers/TF with sim time— every Project AirSim sensor/pose message carries the sim clock in itstime_stampfield, but the bridge stamped ROS headers with its own wall clock at receive time. That breaks time-synchronization downstream (an RGB/depth pair rendered on the same sim tick gets two different stamps, offset by network jitter) and makes header stamps inconsistent with the/clocktopic the bridge itself publishes. The bridge now prefers the message'stime_stampwhen present, falling back to wall clock; TF broadcasts reuse the owning message's stamp.How Has This Been Tested?
Linux (Ubuntu 22.04), Blocks built from this repo at current
mainwith UE 5.7.4, ROS 2 Humble; the bridge node driving a camera+non-physics robot scene and ascene_drone_sensors.jsoncscene./clocknever published and allRawRequest/SetPoseservice calls hung indefinitely (reproduced on repeated runs and two different Blocks builds); a gdb thread dump showed the rclcpp executor blocked inClient::Request → AsyncResult::Wait()with both client worker threads idle and empty queues, i.e. the entry was consumed without completion via theFIsCanceled()path. The Python client against the same sim worked, which localized the fault to the C++ client. After —/clockpublishes at a steady 50 Hz (the node's 20 ms poll),SetPoseround-trips returnsuccess=True, and a downstream RGB-D mapping pipeline ran for extended sessions with no request wedges.mono16payloads read median3for a camera ~3 m from the scene (integer meters). After — median3236(mm), and pointing the camera 45° down from 2 m altitude reads the expected ~2.8 m slant range with millimeter-scale variation; sky pixels arrive as 65535 instead of wrapped small values.message_filtersexact pairing downstream), consistent with/clock.Screenshots and videos (if appropriate):
N/A — behavioral fixes; measurable evidence described above.
🤖 Generated with Claude Code