3D visionHow-to

How to convert PointCloud2 to a point cloud for calibration

Convert a sensor_msgs PointCloud2 into a usable point cloud: read fields, offsets and point_step, rebuild rows, drop NaNs, then run hand-eye calibration.

LumaSpec Vision10 min read Share on LinkedIn

On this page 11 sections
  1. 01What a sensor_msgs PointCloud2 actually is
  2. 02What you need
  3. 03Convert the buffer step by step
  4. 04Worked example: parsing a 2048 × 1224 cloud at 32 bytes per point
  5. 05Where the conversion should run
  6. 06Keep cloud and intrinsics from one pipeline
  7. 07Common mistakes
  8. 08Checks before you run the solver
  9. 09When the usual answer is wrong
  10. 10Where to go next
  11. 11Questions

Your hand-eye solver wants its own cloud object, and the camera's ROS driver hands you a sensor_msgs PointCloud2. The fix is simpler than it looks: PointCloud2 is a self-describing byte buffer, and every offset it needs is written inside the message. This how-to shows you how to read width, height, point_step and the field list, rebuild a structured point cloud of x, y and z floats, prove the parse with a two-point test, and feed the result — captured on the same pipeline as the camera's intrinsics — into a hand-eye calibration you can trust. The arithmetic uses a typical 2.5 MP organized cloud, so you can check your own frame size against it before you commit to a host or a network.

What a sensor_msgs PointCloud2 actually is

ROS moves clouds as sensor_msgs/PointCloud2: a short header of integers plus one long data buffer. width and height describe the grid, and height = 1 marks an unordered list. point_step is the bytes each point occupies, row_step the bytes each row occupies, and fields names the channels inside every point. A typical 3D-camera driver sends x, y and z as FLOAT32 (datatype 7 in the PointField list) at offsets 0, 4 and 8, an rgb or intensity channel at offset 12 or 16, and padding up to point_step.

Because the offsets travel inside the message, the layout is never a guess: you read it, then slice. That is the whole trick behind a clean conversion — the buffer is self-describing.

points = width × height row bytes = point_step × width buffer bytes = row_step × height

Two checks catch every layout bug before it reaches your solver: row_step should equal point_step × width unless the driver pads rows, and data should hold exactly row_step × height bytes. When either check fails, honor the numbers the message carries instead of assuming a packed array.

What you need

  • One captured message from the driver's cloud topic, kept on disk as a regression fixture.
  • The target type: a PCL XYZ point cloud for a PCL solver, or your camera SDK's cloud class.
  • numpy if you work in Python; plain memcpy loops do the same job in C++.
  • For the calibration half: a calibration target, the robot pose for every capture, and camera_info from the same driver instance.

Convert the buffer step by step

  1. Read the header. Take width, height, point_step and row_step from the message. Height > 1 means an organized grid; height = 1 means the driver sent an unordered list and the pixel geometry is not in this message.
  2. List the fields. Note each name, its datatype and its offset. You need x, y and z; take rgb or intensity too if the downstream step wants color.
  3. Check the buffer size. data must hold row_step × height bytes. If data is longer, rows are padded, and point (row r, column c) starts at data[r × row_step + c × point_step] — not at a flat per-point index.
  4. Check endianness. is_bigendian carries the float byte order. x86 and ARM hosts are little-endian, so a big-endian cloud needs a byte swap before the cast.
  5. Build the view. Read x, y and z at their declared offsets with itemsize = point_step, then reshape to (height, width) so pixel-based operations keep working.
  6. Mark invalid returns. Points with NaN coordinates are the camera's no-return pixels. Count them instead of dropping silently: a high NaN share means a bad capture, not a bad parser.
  7. Convert into the target type. Fill the PCL or SDK cloud row by row, keep width, height and is_dense, and stamp it with the capture time from the message header, not the conversion time.
import numpy as np

def cloud_to_xyz(msg):
    """View the XYZ floats of a sensor_msgs PointCloud2 without copying.

    msg exposes .width, .height, .point_step, .fields and .data.
    """
    off = {f.name: f.offset for f in msg.fields}   # documented PointField names
    dt = np.dtype({"names": ["x", "y", "z"],
                   "formats": [np.float32, np.float32, np.float32],
                   "offsets": [off["x"], off["y"], off["z"]],
                   "itemsize": msg.point_step})
    n = msg.width * msg.height                     # height = 1 if unordered
    return np.frombuffer(msg.data, dtype=dt, count=n).reshape(msg.height, msg.width)

The test that proves the parser uses a two-point fixture with known bytes:

def test_cloud_to_xyz():
    pad = bytes(20)                    # rgb, intensity and padding, untouched
    data = (np.array([1, 2, 3], np.float32).tobytes() + pad
            + np.array([4, 5, 6], np.float32).tobytes() + pad)
    msg = Stub(width=2, height=1, point_step=32,
               fields=[Stub(name="x", offset=0), Stub(name="y", offset=4),
                       Stub(name="z", offset=8)],
               data=data)
    pts = cloud_to_xyz(msg)
    assert pts["x"][0, 0] == 1.0 and pts["y"][0, 1] == 5.0 and pts["z"][0, 1] == 6.0

Here Stub stands for any object carrying those attribute names; your real message object does. The fixture is 64 bytes: 32 per point × 2 points, of which 12 carry XYZ and 20 carry the untouched tail. When the asserts pass, the parser honors offsets and stride; when they fail, the bug is in your view, not in the driver.

Worked example: parsing a 2048 × 1224 cloud at 32 bytes per point

A typical organized cloud from a 2.5 MP 3D camera announces these values in its own header. Read them from the message; none is guessed:

Value Example Source
width 2048 columns message header
height 1224 rows message header
points 2,506,752 2048 × 1224
point_step 32 bytes message header
fields x at 0, y at 4, z at 8, rgb at 16, FLOAT32 message fields
row_step 65,536 bytes message header
data 80,216,064 bytes buffer length

The rule in words: the buffer must equal row_step × height, every point occupies point_step bytes, and every channel sits at its declared offset inside those bytes.

The arithmetic, written out:

  • Row check: 32 B × 2048 = 65,536 B, which equals row_step, so this driver sends no row padding.
  • Buffer check: 65,536 B × 1224 = 80,216,064 B, which equals the data length, so the parse is sound.
  • Slices: x in bytes 0–3, y in 4–7, z in 8–11 of every 32-byte point; rgb sits at 16–19, and bytes 12–15 and 20–31 are padding.

Result: an organized (1224, 2048) float32 grid of about 80.2 MB per frame.

The decision it forces is about bandwidth. At 10 captures per second the raw stream is 80,216,064 × 10 = 802 MB/s, about 6.4 Gbit/s. Gigabit Ethernet moves roughly 118 MB/s of usable payload — about 1.5 of these frames per second — and a 10 GbE link at about 1.15 GB/s tops out near 14. So: one frame every 700 ms fits a gigabit link; sustained 10 Hz at this size needs 10 GbE or a smaller point_step; anything faster needs the driver and the solver on one host.

Where the conversion should run

Keep driver, converter and solver on one machine when you can. A 32 GB controller holds roughly 300 of these frames in memory (32,000 MB ÷ 110 MB per frame in flight), enough for a capture sequence plus the solver's working set, and separate LAN ports let the camera, the robot controller and the plant network sit on different links so capture timing stays steady.

If you are specifying that receiving machine, a fanless vision controller with PoE camera ports does the subscribing, parsing and solving in one box.

Vision controllersHikrobot MV-VC5525-256G70-NN VC5000 Vision Controller, 12th-I5, 32GB+256G SSD, Supported PCIEMV-VC5525-256G70-NN
LAN ports
5+ LAN ports
USB 3 ports
3-4 USB 3 ports
PoE
PoE camera ports
Light outputs
8 built-in channels
$4,479.00 · quotedView and quoteBuild a system with it

Match the controller's LAN ports and memory to the frame rate your camera sends before you size the rest of the cell; our free expert support with every order can check the fit with you.

Keep cloud and intrinsics from one pipeline

If the solver also wants the camera's intrinsic matrix, capture the cloud and camera_info through the same driver instance. Re-acquiring through a second path puts a different capture — different optics temperature, different timestamps — into the math, and the resulting extrinsic error lands in every pick. Take robot poses from the controller's own readout rather than commanded values, and record one capture per pose.

What the intrinsics actually do in the solve is covered in camera intrinsics for hand-eye calibration: fix the pose first. The nine-point method behind most driver wizards is explained in robot hand-eye calibration: how nine-point alignment works. Before any production motion, prove the result on a physical target with hand-eye calibration accuracy verification.

Common mistakes

  • Reshaping a height-1 cloud into a grid. An unordered list has no pixel geometry; no reshape recovers it. Subscribe to the driver's organized topic instead.
  • Reading width × height × 3 floats. That walks off the true stride whenever point_step > 12 bytes, so neighboring channels get parsed as coordinates. Always step by point_step.
  • Ignoring row padding. When row_step > point_step × width, a flat view drifts by the padding after the first row. Index row by row.
  • Assuming little-endian without reading is_bigendian. Most drivers send little-endian; the flag is one bit to check.
  • Decimating before the solve. Voxel filters shift surfaces, and the shift lands in the calibration. See point cloud downsampling distortion: why decimation shifts points.
  • Losing color. Rebuild the rgb channel at its declared offset or viewers show empty color; the RGB-only blank point cloud fix walks through that channel.

Checks before you run the solver

  • The two size checks pass: 32 B × 2048 = row_step, and row_step × 1224 = data length, with your camera's own numbers substituted.
  • The first point's x, y, z matches your camera viewer's cursor readout at the same pixel.
  • The NaN count matches the camera's own invalid-return count for the scene; cross-check the confidence map if the driver publishes one — decoding confidence image bitflags shows how.
  • A distance measured inside the cloud matches a gauge block within the camera's spec; the method is in depth map to real distance calibration.
  • A plotted surface looks coherent. Interleaved stripes mean wrong row handling, and scattered holes mean depth drops — see fixing missing points in a 3D camera point cloud.

When the usual answer is wrong

If your camera's ROS driver already offers a capture-and-save service that writes the SDK's native file format, load that file in the solver and skip the byte-level parse: the native route carries every channel losslessly, and the SDK's own loader stays the single source of truth.

If the solver is PCL end to end, pcl::fromPCLPointCloud2 does the whole conversion in one call and hand parsing adds nothing.

And if calibration keeps failing after the parse is proven, the problem is upstream: mounting, target quality or camera placement. The 3D camera working distance and positioning guide covers the geometry before any software runs.

Where to go next

For hardware: the 3D sensors category lists laser profilers, structured-light and ToF cameras, and the camera finder filters them by type. Structured-light and ToF depth cameras output exactly the organized, camera-frame clouds this pipeline assumes:

3D sensorsHikrobot MV-DPS200P-02 Projected structured-light 3D cameraMV-DPS200P-02
Type
Stereo / structured light
$8,829.00 · quotedView and quote
3D sensorsHikrobot MV-DT01WMU ToF sensing cameraMV-DT01WMU
Type
Depth / ToF camera
Price on requestView and quote

Browse the 3D topic page for the rest of the calibration chain, or contact us if you want a second pair of eyes on a build in progress.

Questions

Why does my PointCloud2 arrive with height 1 when the camera is a 2D sensor grid?

The driver published an unordered cloud: height is 1 and width carries the point count, so the pixel grid is gone. Most 3D camera drivers also publish an organized topic that keeps rows and columns; subscribe to that one when your solver expects a grid. If only the unordered topic exists, the message alone cannot rebuild the grid: record the organized depth image alongside and match captures by timestamp.

Can I use PCL's own conversion instead of hand-rolled byte parsing?

Yes. pcl::PCLPointCloud2 uses the same binary layout as sensor_msgs PointCloud2, so fromPCLPointCloud2 converts into a PCL cloud of XYZ points in one call, and toPCLPointCloud2 goes the other way. The data is copied once; at 32 bytes per point that copy costs a few tens of milliseconds for a 2.5 MP cloud on a desktop CPU. Hand parsing still earns its keep when the target is an SDK class PCL does not know.

How much memory does one converted 2 MP cloud take?

For the worked example's 2048 × 1224 cloud the wire buffer is about 80 MB at 32 bytes per point. A converted XYZ-only cloud holds 12 bytes per point: 2,506,752 points × 12 = about 30 MB. Holding wire buffer and converted cloud in RAM at the same time needs about 110 MB per frame, so a 32 GB controller queues hundreds of frames with room for the solver.

Do I still need camera intrinsics if the solver only consumes XYZ points?

For a pure 3D hand-eye solve, no: the solver works on camera-frame points and never touches the intrinsics. The moment the pipeline also fits 2D features, projects CAD models into the image, or fuses color with pixel coordinates, intrinsics enter the math and must come from the same capture path as the cloud. Mixing a cloud from one driver with intrinsics from another is the classic source of a sub-millimetre bias that looks like robot error.

Is it safe to downsample the cloud before calibration to speed up the solve?

Random or grid sampling keeps exact points and is harmless for a hand-eye solve at moderate ratios. Voxel-grid averaging is different: it replaces each voxel's points with their centroid, moving surfaces by up to half a voxel size, and that shift lands in the calibration result. Convert first, calibrate at native density, and decimate only for visualization downstream.