3D visionHow-to

2.5D height map to 3D point cloud: pixels to XYZ

Turn a 16-bit 2.5D height map into a scaled XYZ point cloud: decode Z around the 32768 offset, set the three resolutions and build the X and Y planes.

LumaSpec Vision11 min read Share on LinkedIn

On this page 9 sections
  1. 01What is stored in a 2.5D height map file?
  2. 02What are the conversion steps, in order?
  3. 03How do you scale the 16-bit Z counts to millimetres?
  4. 04What do ZILLatRes, ZILVerRes and TransportRate mean?
  5. 05How do you build the X and Y coordinate planes?
  6. 06How do you mask invalid pixels before assembling?
  7. 07How do you check the cloud size and scale?
  8. 08Why does the cloud come out flat, stretched or mirrored?
  9. 09Questions

A profiler that saves its result as a height map hands you a 16-bit grayscale image and three calibration numbers, not a point cloud. Open the PNG in an image viewer and you see gray noise; load it into a mesh tool and you get a distorted sheet, because nothing in the file says what one count means in millimetres. This guide gives you the conversion: decode the Z values around the mid-scale offset, build the X and Y planes from the three resolutions, mask the background, assemble the cloud and size-check it against a known part. The recipe works for any laser profiler or 2.5D system that stores counts in a 16-bit image, whatever wrote the file.

What is stored in a 2.5D height map file?

It is a single-channel 16-bit image whose pixels hold raw counts proportional to surface height around the mid-scale value, plus three calibration numbers — lateral, vertical and transport — that the file itself does not carry.

Rows are captured profile lines, one per step of the part or belt; columns are samples across each laser line. In the example map 1,000 lines were captured (NumberOfZILCaptured := 1000) and the valid surface sits between counts 30058 and 39762. A count near 32768 means the surface is at the reference height; higher counts are higher surfaces, lower counts lower ones. That is why the picture looks like noise: the heights are the small differences around the middle of the 16-bit range of 0 to 65535.

The three numbers turn counts into millimetres: ZILLatRes := 0.056 is the lateral resolution in mm per column, ZILVerRes := 0.0085 the vertical resolution in mm per count, and TransportRate := 0.1 the part travel in mm per captured line. Without them the map is just a picture. A 2.5D map also holds exactly one height per pixel, so it is a height field, not a full scan — vertical walls and undercuts have no place in it.

Laser profilers write this format whenever their capture software stores the per-line profile instead of the finished cloud, as the sensor below does.

3D sensorsHikrobot MIS-DP3850-01P 3D laser profile sensorMIS-DP3850-01P
Type
Laser profiler
$12,379.00 · quotedView and quote

Decoded clouds feed straight into inspection work such as surface defect detection with a 3D laser line profiler. Note the difference from a camera depth map, too: there is no pinhole deprojection calculation here, because each pixel is already a height — the conversion below is pure scaling.

What are the conversion steps, in order?

The pipeline is: read the image, threshold the valid count band, crop to the captured lines, convert to floating point, scale by the Z resolution around the mid-scale offset, generate the X and Y planes, combine them into one 3D model and display it coloured by height.

  1. read_image loads the 16-bit map, and get_image_size returns Width in columns and Height in rows.
  2. threshold keeps the valid count band — 30058 to 39762 in the example — and reduce_domain confines later operations to that region.
  3. crop_rectangle1 trims the map to the captured lines, rows 0 to NumberOfZILCaptured − 1, so the coordinate planes match the map exactly.
  4. convert_image_type switches the counts from 16-bit integers to real floats before any arithmetic.
  5. tuple_pow computes the mid-scale offset 2^15 = 32768, and scale_image applies Z = (count − 32768) × ZILVerRes as one multiply-add.
  6. gen_image_surface_first_order builds the X plane with slope TransportRate per row and the Y plane with slope ZILLatRes per column.
  7. xyz_to_object_model_3d combines the three equal-size images into one 3D object model.
  8. visualize_object_model_3d displays it with the display parameters set so the cloud paints by Z.

Steps 2 to 4 are housekeeping, step 5 is the physics and steps 6 and 7 are bookkeeping; the sections below take each real operation in turn. Starting from a depth camera instead of a profiler map changes only the inputs — the same per-axis scaling applies, as the per-axis scale factor guide for depth images shows.

How do you scale the 16-bit Z counts to millimetres?

Decode each pixel with Z = (count − 32768) × 0.0085 mm: subtract the mid-scale offset 2^15 = 32768, then multiply by the vertical resolution ZILVerRes of 0.0085 mm per count.

The offset exists because the sensor writes an unsigned image. Counts run 0 to 65535, and the zero-height reference sits at mid-scale, 2^15 = 32768 (65535 ÷ 2 = 32767.5, and the convention rounds up), leaving roughly half the range for material above the reference and half for dips below it. scale_image computes out = in × mult + add, so mult = 0.0085 and add = −32768 × 0.0085 = −278.528 do the subtraction and the scaling in one pass.

Z = (count − 32768) × ZILVerRes out = in × 0.0085 + (−32768 × 0.0085)

Run the example band through the formula: 30058 gives (30058 − 32768) × 0.0085 = −23.0 mm and 39762 gives +59.4 mm, so that part fills 9704 counts × 0.0085 = 82.5 mm of the height window. If your source is a depth camera rather than a profiler map, the file usually stores true distance already: the offset is then 0 and the Z scale is the storage unit, and the depth value versus true distance conversion explains when that distinction matters.

What do ZILLatRes, ZILVerRes and TransportRate mean?

ZILLatRes is the lateral resolution in millimetres per column, ZILVerRes the vertical resolution in millimetres per count, and TransportRate the part travel in millimetres per captured line — the three numbers together are the entire calibration of the map.

ZILLatRes and ZILVerRes come from the sensor: lateral is the profile width divided by the samples per profile, vertical is the height range divided by the count range. The example's 0.056 mm per column and 0.0085 mm per count are typical of a mid-range profiler. TransportRate is different: it is your motion, not the sensor's — the distance the part moves while one line is captured.

Compute the pitch as belt speed divided by capture rate. A belt at 100 mm/s captured at 1,000 lines per second gives 100 ÷ 1000 = 0.1 mm per line, the example's TransportRate. Change either side and the pitch changes: at 250 mm/s with the same capture rate the pitch is 0.25 mm per line, and if you still decode with 0.1, a 100 mm part measures 400 true lines × 0.1 = 40 mm.

Line-scan camera line rate
Units
Pixel on the part
– mm
Line rate needed
– kHz
Longest exposure
– µs
Data rate
– MB/s
Lines per metre
–
Encoder setting
–

Square pixels: line rate = speed / pixel on the part; the exposure must end within one line period. Encoder: lines per pulse = (π × roller diameter / pulses per revolution) / pixel on the part.

Find line scan cameras Open the full calculator

Every profiler data sheet prints the vertical and lateral resolutions this conversion needs; the transport pitch stays yours to compute. The profilers below all write height maps in this format.

3D sensorsHikrobot MIS-DP3850-01P 3D laser profile sensorMIS-DP3850-01P
Type
Laser profiler
$12,379.00 · quotedView and quote
3D sensorsHikrobot MV-DP4180-03P 3D Laser Profile SensorMV-DP4180-03P
Type
Laser profiler
$12,389.00 · quotedView and quote
3D sensorsHikrobot MV-DPS200P-02 Projected structured-light 3D cameraMV-DPS200P-02
Type
Stereo / structured light
$8,829.00 · quotedView and quote

Line-scan 2.5D systems run the same arithmetic at much higher capture rates.

3D sensorsHikrobot MV-HLVS-16K100-L60 Line-scan 2.5D, 16K resolution, Sampling rate 120 kHz, Emitting area length 600 mmMV-HLVS-16K100-L60
Type
2.5D inspection
Price on requestView and quote

The MV-HLVS-16K100-L60 samples at 120 kHz on a 16K sensor with a 600 mm emitting area. Holding a 0.1 mm pitch at 120 kHz takes a 12,000 mm/s transport; at a 1,000 mm/s belt you would capture every 12th line — an effective 10 kHz — to keep the same pitch.

How the three scales interact without distorting the geometry is its own subject; the point cloud x y z resolution guide covers the combinations that stay metric.

How do you build the X and Y coordinate planes?

Each coordinate plane is an image the same size as the map whose pixel value is a position: the X plane carries TransportRate per row, the Y plane carries ZILLatRes per column, and both are measured from the image centre.

gen_image_surface_first_order generates such a plane. Its value at each pixel is Alpha × (row − Row) + Beta × (column − Column) + Gamma, where Alpha is the slope in the vertical (row) direction, Beta the slope in the horizontal (column) direction and Gamma a constant. The example's X plane passes Alpha = TransportRate = 0.1, Beta = 0 and Gamma = 0, with the reference at Row = NumberOfZILCaptured / 2 = 500 and Column = Width / 2, so X = 0.1 × (row − 500) mm. The Y plane swaps the slopes — Alpha = 0, Beta = ZILLatRes = 0.056 — so Y = 0.056 × (column − Width/2) mm. Extracting one axis at a time is why the other axis's slope is 0.

X = TransportRate × (row − NumberOfZILCaptured/2) = 0.1 × (row − 500) mm Y = ZILLatRes × (column − Width/2) = 0.056 × (column − Width/2) mm

Gamma, the zeroth-order term, is a constant for the whole plane; at 0 the cloud centres on the map's middle pixel, which keeps the part symmetric around the origin and the later pose maths simple. If the cloud comes out mirrored along travel because the rows were captured against the motion, negate the X slope or rotate the finished cloud 180° about Z — nothing else changes.

How do you mask invalid pixels before assembling?

Two thresholds clean the map: threshold at 30058 to 39762 on the raw counts keeps the surface and drops the background, then a threshold at 1 to 9999999 on the converted counts rejects pure-black pixels that would decode into false points at the bottom of the cloud.

The first band selects pixels that hold real surface: background in a profiler map sits at 0 or pinned at the range rails, and decoding it would smear the cloud flat. reduce_domain then confines every later operation to that region, and crop_rectangle1 cuts the map to exactly NumberOfZILCaptured lines. Equal sizes matter because xyz_to_object_model_3d combines the planes pixel-by-pixel: X, Y and Z must all be Width × NumberOfZILCaptured, or the call fails.

Keep the band tight. A band wider than the part's real heights adds a floor of rail pixels; a band narrower than the tallest feature slices the top off the part. Set it from the histogram of a known-good capture, and both errors stay out of every measurement you make afterwards.

How do you check the cloud size and scale?

The example map converts to a cloud 100 mm long, 145.6 mm wide at 2,600 columns and 82.5 mm tall in its valid band, holding 2.6 million points or about 31 MB as 32-bit floats.

  • Travel: 1,000 lines × 0.1 mm/line = 100 mm
  • Width: 2,600 columns × 0.056 mm/column = 145.6 mm
  • Height window: (30058 − 32768) × 0.0085 = −23.0 mm to (39762 − 32768) × 0.0085 = +59.4 mm, a band of 9704 counts × 0.0085 = 82.5 mm
  • Points: 1,000 × 2,600 = 2,600,000, which is 2,600,000 × 12 bytes = 31.2 MB as XYZ 32-bit floats

Then test the scales on hardware. Scan a 10.00 mm gauge block: the decoded step from base to top must read 10.00 mm, and one count of error is only 0.0085 mm, so anything beyond ±1 count points at a wrong ZILVerRes or offset. Drive the belt a known 50.0 mm between captures: the same feature must move 50.0 ÷ 0.1 = 500 lines. If it moves 200, the real pitch was 0.25 mm per line and your TransportRate is stale.

Why does the cloud come out flat, stretched or mirrored?

Flat, stretched, mirrored and spiked clouds each have one cause: the offset, the transport pitch, the row direction and the missing mask, in that order of likelihood.

  • Flat sheet pinned near zero — the file was already zero-based and the 32768 offset pushed every count to the bottom rail. Rerun with the offset at 0.
  • Stretched or squashed along travel — TransportRate no longer matches the belt: slip, a speed change, or a pitch copied from another station. At a true 0.25 mm per line decoded as 0.1, a 100 mm part measures 40 mm.
  • Mirrored along travel — the rows were captured against the motion. Negate the X slope or rotate 180° about Z.
  • Ridges at regular intervals — the capture was free-running instead of encoder-triggered, so the lines are unevenly spaced in reality. Trigger from the encoder and set the pitch with the encoder ticks per pixel method.
  • Edge spikes — the mask was skipped, so rail counts decoded as towers. Re-apply the count-band threshold before scaling.

One limit no parameter fixes: a height map holds a single height per pixel, so steep walls fold over and undercuts disappear. When the geometry needs those, move to a full 3D scan instead of pushing the height map further.

If you are specifying hardware for this pipeline, start from the 3D sensors category or the camera finder; the 3D topic page collects the rest of the decoding and calibration guides. In-stock items ship within one business day, and every order includes free expert support.

Build the whole station

The configurator matches a camera, lens, light, light controller and cables to each other, step by step, and lists only parts that fit what you already picked.

Start with the camera

Questions

Where do I get the three resolution numbers if the capture software does not print them?

Measure them from a capture you trust. Scan a gauge block of known height and divide: a 10.00 mm block spanning 1,176 counts gives 0.0085 mm per count. Image a ruler or a part of known width for millimetres per column. Drive the belt a measured distance and divide by the captured lines for millimetres per line.

Can I convert a depth camera's depth map the same way?

The structure is identical, the numbers are not. A depth camera stores true distance, so the Z offset is 0 and the scale is the storage unit, usually 1 mm per count. X and Y need the pinhole model instead: X = (column − cx) × Z ÷ fx with the focal length in pixels.

What bit depth should I save the height map in?

Save 16-bit. An 8-bit map keeps 256 levels, and at 0.0085 mm per count the whole span is 2.2 mm — too coarse for metrology. 16-bit gives 65,536 levels with the 32768 midpoint, and 32-bit float files skip the offset altogether. Use a lossless format such as 16-bit PNG or TIFF; JPEG destroys counts.

How do I display the cloud coloured by height?

Feed the Z plane to the viewer as a colour attribute. In the example the display parameters are set to 'color_attrib' = 'coord_z', 'lut' = 'color1' and 'alpha' = 0.5, which paints each point by its height. Every 3D viewer has an equivalent colour-by-scalar mode, and the result is the height map again — but scaled in real millimetres.