Skip to content

Commit 37bdd2f

Browse files
andrewjongclaude
andcommitted
depth: transmit raw float16 meters bit-exactly (encoding 16FC1), decode 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>
1 parent 06915de commit 37bdd2f

9 files changed

Lines changed: 124 additions & 28 deletions

File tree

client/python/airsimv1_scripts_migrated/computer_vision/cv_capture.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def camera_callback(camera_info, camera_name):
5757
responses.update(drone.get_images(camera_id="front_left", image_type_ids=[ImageType.SCENE]))
5858

5959
for i, response in enumerate(responses.values()):
60-
if response["encoding"] == "16UC1":
60+
if response["encoding"] in ("16UC1", "16FC1"):
6161
projectairsim_log().info("Type %s, size %d, pos %s" % (response["encoding"], len(response["data"]), pprint.pformat([response["pos_x"],response["pos_y"],response["pos_z"]])))
6262
filename = os.path.normpath(os.path.join(tmp_dir, str(x) + "_" + str(i) + '.pfm'))
6363
else:

client/python/airsimv1_scripts_migrated/computer_vision/cv_mode.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def to_quaternion(pitch, roll, yaw):
6666
#responses.update(drone.GetImages("back_center", [ImageType.DISPARITY_NORMALIZED, ImageType.SURFACE_NORMALS]))
6767

6868
for idx, response in enumerate(responses.values()):
69-
if response["encoding"] == "16UC1":
69+
if response["encoding"] in ("16UC1", "16FC1"):
7070
filename = os.path.join(tmp_dir, str(x) + "_" + str(idx) + ".pfm")
7171
else:
7272
filename = os.path.join(tmp_dir, str(x) + "_" + str(idx) + ".png")

client/python/airsimv1_scripts_migrated/multirotor/hello_drone.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ async def main():
9292

9393
for idx, image in enumerate(images.values()):
9494
img_np = unpack_image(image)
95-
if image["encoding"] == "16UC1":
95+
if image["encoding"] in ("16UC1", "16FC1"):
9696
file_save_path = os.path.join(tmp_dir, str(idx) + ".pfm")
9797
else:
9898
file_save_path = os.path.join(tmp_dir, str(idx) + ".png")

client/python/airsimv1_scripts_migrated/multirotor/multi_agent_drone.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ async def main():
6666

6767
responses = [v for d in (responses1, responses2) for v in d.values()]
6868
for idx, response in enumerate(responses):
69-
if response["encoding"] == "16UC1":
69+
if response["encoding"] in ("16UC1", "16FC1"):
7070
filename = os.path.join(tmp_dir, str(idx) + ".pfm")
7171
else:
7272
filename = os.path.join(tmp_dir, str(idx) + ".png")

client/python/example_user_scripts/camera_image_types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ async def main():
6767

6868
for index, image in enumerate(images.values()):
6969
img_np = unpack_image(image)
70-
if image["encoding"] == "16UC1":
70+
if image["encoding"] in ("16UC1", "16FC1"):
7171
file_save_path = os.path.join(save_path, str(index) + ".pfm")
7272
else:
7373
file_save_path = os.path.join(save_path, str(index) + ".png")

client/python/projectairsim/src/projectairsim/utils.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -417,8 +417,14 @@ def unpack_image(image):
417417
Returns:
418418
The image in openCV decoded form
419419
"""
420-
# 16UC1 is used for serializing depth images
421-
if image["encoding"] == "16UC1":
420+
# 16FC1 is used for serializing depth images: raw IEEE 754 half-precision
421+
# (binary16) METERS, little-endian, bit-exact with the sim's fp16 render
422+
# target. Sky / no-hit pixels arrive as +inf.
423+
if image["encoding"] == "16FC1":
424+
img_dtype = "float16"
425+
img_shape = [image["height"], image["width"]]
426+
# 16UC1: legacy uint16 depth from older sims
427+
elif image["encoding"] == "16UC1":
422428
img_dtype = "uint16"
423429
img_shape = [image["height"], image["width"]]
424430
elif image["encoding"] == "AVX":

ros/node/projectairsim-rosbridge/src/projectairsim_rosbridge/msg_converter.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,13 @@ def convert_image_to_ros(self, projectairsim_topic_name, projectairsim_image):
160160
return self.convert_image_16uc1_to_ros(
161161
projectairsim_topic_name, projectairsim_image
162162
)
163+
elif projectairsim_image["encoding"] == "16FC1":
164+
return self.convert_image_16fc1_to_ros(
165+
projectairsim_topic_name, projectairsim_image
166+
)
163167
else:
164168
raise ValueError(
165-
f"Can only handle image encoding BGR or 16UC1, not \"{projectairsim_image['encoding']}\""
169+
f"Can only handle image encoding BGR, 16UC1 or 16FC1, not \"{projectairsim_image['encoding']}\""
166170
)
167171

168172
def convert_image_bgr8_to_ros(
@@ -229,6 +233,42 @@ def convert_image_16uc1_to_ros(
229233

230234
return image
231235

236+
def convert_image_16fc1_to_ros(
237+
self, projectairsim_topic_name, projectairsim_image_16fc1
238+
):
239+
"""
240+
Convert a Project AirSim 16FC1 image message into a ROS image message.
241+
242+
16FC1 is raw IEEE 754 half-precision (binary16) depth in METERS,
243+
little-endian, bit-exact with the sim's fp16 render target. Decoded to
244+
the standard ROS 32FC1 float-meters depth image; non-finite pixels
245+
(sky / no hit arrive as +inf) become NaN per the ROS depth convention.
246+
247+
Arguments:
248+
projectairsim_topic_name - The Project AirSim topic name
249+
projectairsim_image_16fc1 - The 16FC1 image message received from the Project AirSim topic
250+
251+
Return:
252+
(return) - Corresponding ROS Image message
253+
"""
254+
image = rossensmsg.Image()
255+
image.header.stamp = self.ros_node.get_time_now_msg()
256+
# image.header.frame_id must be set by caller
257+
258+
image.height = projectairsim_image_16fc1["height"]
259+
image.width = projectairsim_image_16fc1["width"]
260+
image.encoding = "32FC1"
261+
image.is_bigendian = projectairsim_image_16fc1["big_endian"]
262+
263+
nparray = np.frombuffer(
264+
projectairsim_image_16fc1["data"], dtype=np.float16
265+
).astype(np.float32)
266+
nparray[~np.isfinite(nparray)] = np.nan
267+
image.data = nparray.tobytes()
268+
image.step = image.width * 4
269+
270+
return image
271+
232272
def convert_imu_to_ros(self, projectairsim_topic_name, projectairsim_msg):
233273
"""
234274
Convert a Project AirSim IMU sensor message into a ROS Imu message.

ros/projectairsim_ros2_cpp/include/projectairsim_ros2_cpp/ros2_conversion_utils.hpp

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#include <array>
55
#include <cmath>
66
#include <cstdint>
7+
#include <cstring>
78
#include <filesystem>
89
#include <limits>
910
#include <string>
@@ -342,6 +343,35 @@ inline void PopulateCameraInfoFromJson(const json& msg,
342343
FillFixedArray(msg.value("projection_matrix", json::array()), &camera_info->p);
343344
}
344345

346+
// Convert an IEEE 754 half-precision (binary16) bit pattern to float32.
347+
inline float HalfBitsToFloat(uint16_t half_bits) {
348+
const uint32_t sign = static_cast<uint32_t>(half_bits & 0x8000u) << 16;
349+
uint32_t exponent = (half_bits >> 10) & 0x1Fu;
350+
uint32_t mantissa = half_bits & 0x3FFu;
351+
uint32_t float_bits;
352+
if (exponent == 0) {
353+
if (mantissa == 0) {
354+
float_bits = sign; // +/- zero
355+
} else {
356+
// Subnormal half: normalize into a float32 exponent/mantissa.
357+
exponent = 127 - 15 + 1;
358+
while ((mantissa & 0x400u) == 0) {
359+
mantissa <<= 1;
360+
--exponent;
361+
}
362+
mantissa &= 0x3FFu;
363+
float_bits = sign | (exponent << 23) | (mantissa << 13);
364+
}
365+
} else if (exponent == 31) {
366+
float_bits = sign | 0x7F800000u | (mantissa << 13); // inf / NaN
367+
} else {
368+
float_bits = sign | ((exponent - 15 + 127) << 23) | (mantissa << 13);
369+
}
370+
float result;
371+
std::memcpy(&result, &float_bits, sizeof(result));
372+
return result;
373+
}
374+
345375
inline bool PopulateImagePayloadFromJson(const json& msg,
346376
sensor_msgs::msg::Image* image) {
347377
image->height = static_cast<uint32_t>(NumberOr(msg, "height"));
@@ -355,11 +385,34 @@ inline bool PopulateImagePayloadFromJson(const json& msg,
355385
return true;
356386
}
357387
if (encoding == "16UC1") {
388+
// Legacy sims: uint16 depth. Kept for compatibility with older servers.
358389
image->encoding = "mono16";
359390
image->data = BytesFromJsonString(msg.value("data", ""));
360391
image->step = 2 * image->width;
361392
return true;
362393
}
394+
if (encoding == "16FC1") {
395+
// Depth as raw IEEE 754 half-precision (binary16) METERS, little-endian,
396+
// bit-exact with the sim's fp16 render target. Decode to the standard
397+
// ROS 32FC1 float-meters depth image; non-finite pixels (sky / no hit
398+
// arrive as +inf) become NaN per the ROS depth convention.
399+
const auto payload = BytesFromJsonString(msg.value("data", ""));
400+
const size_t pixel_count = payload.size() / 2;
401+
image->encoding = "32FC1";
402+
image->step = 4 * image->width;
403+
image->data.resize(pixel_count * 4);
404+
const uint8_t* src = payload.data();
405+
float* dst = reinterpret_cast<float*>(image->data.data());
406+
for (size_t i = 0; i < pixel_count; ++i, src += 2) {
407+
const uint16_t bits = static_cast<uint16_t>(src[0]) |
408+
(static_cast<uint16_t>(src[1]) << 8);
409+
const float meters = HalfBitsToFloat(bits);
410+
dst[i] = std::isfinite(meters)
411+
? meters
412+
: std::numeric_limits<float>::quiet_NaN();
413+
}
414+
return true;
415+
}
363416
return false;
364417
}
365418

unreal/Blocks/Plugins/ProjectAirSim/Source/ProjectAirSim/Private/Sensors/ImagePackingAsyncTask.cpp

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,30 +28,26 @@ void FImagePackingAsyncTask::DoWork() {
2828

2929
// Handle Depth image requests here.
3030
// 1. Currently, we do not support Compression for depth images
31-
// 2. We also do not support sending back Floats since our ImageResponse
32-
// Message is limited to uint8 for now. Instead, we convert Float16 from
33-
// Unreal to uint16 and then pack it in two uint8s that have to be unpacked
34-
// properly on client side. NOTE: This case also handles PixelsAsFloat
35-
// implicitly i.e. it ignores it and always sends back uint16 for depth
31+
// 2. Depth is transmitted as the render target's IEEE 754 half-precision
32+
// (binary16) METERS, bit-exact: each pixel's FFloat16 bit pattern is
33+
// packed little-endian into two uint8s (encoding "16FC1" below) and
34+
// reinterpreted as float16 on the client side. No value conversion
35+
// happens here, so no precision is lost beyond the fp16 render target
36+
// itself, and there is no range cap: sky / no-hit pixels arrive as +inf
37+
// for clients to map to their own invalid-depth convention. NOTE: This
38+
// case also handles PixelsAsFloat implicitly i.e. it ignores it and
39+
// always sends back float16 for depth
3640
if (bIsDepthImage && !ImageRequest.bCompress) {
3741
ImgResponse.ImageDataUInt8.resize(
3842
RenderResult.Width * RenderResult.Height * 2 * sizeof(uint8));
3943

4044
uint8* DstPtr = ImgResponse.ImageDataUInt8.data();
4145
for (const auto& SrcPixel : RenderResult.UnrealImageFloat) {
42-
// The depth materials write METERS to R (SceneDepth cm / 100). The
43-
// wire format is uint16 millimeters ("16UC1 ... mm" below), so
44-
// convert; casting the raw meters value truncated depth to a 1 m
45-
// quantization. Saturate at the uint16 ceiling (65.535 m) — sky /
46-
// no-hit pixels are huge (or inf) and the bare cast wrapped them
47-
// around into phantom finite depths.
48-
float DepthMilli = SrcPixel.R.GetFloat() * 1000.0f;
49-
uint16 DepthUint16 =
50-
(!FMath::IsFinite(DepthMilli) || DepthMilli >= 65535.0f)
51-
? static_cast<uint16>(65535)
52-
: static_cast<uint16>(DepthMilli);
53-
*DstPtr++ = static_cast<uint8>(DepthUint16 & 0xFF); // least significant byte
54-
*DstPtr++ = static_cast<uint8>((DepthUint16 >> 8) & 0xFF); // most significant byte
46+
// The depth materials write METERS to R; transmit the fp16 bit
47+
// pattern as-is (see the encoding comment above).
48+
const uint16 DepthHalfBits = SrcPixel.R.Encoded;
49+
*DstPtr++ = static_cast<uint8>(DepthHalfBits & 0xFF); // least significant byte
50+
*DstPtr++ = static_cast<uint8>((DepthHalfBits >> 8) & 0xFF); // most significant byte
5551
}
5652
}
5753
// Normal RGB images without compression or PixelsAsFloat requested
@@ -137,8 +133,9 @@ void FImagePackingAsyncTask::DoWork() {
137133
ImgEncoding = "BGR";
138134
}
139135
} else { // bIsDepthImage
140-
// 16-bit unsigned, 1 channel for depth in mm
141-
ImgEncoding = "16UC1";
136+
// IEEE 754 half-precision (binary16), 1 channel, depth in METERS,
137+
// little-endian — bit-exact with the fp16 render target
138+
ImgEncoding = "16FC1";
142139
}
143140

144141
ImageMessages.emplace(

0 commit comments

Comments
 (0)