Processing tools for 3DMakerPro JMK2 handheld scanner exports (Livox Mid-360 lidar + IMU, optional camera and GNSS).
The device exports raw sensor data only — no poses, no trajectory. These tools turn a recorded session into a single merged, gravity-levelled, deduplicated point cloud, and let you inspect the result in a browser.
bag ──▶ bag2pcd.py per-frame PCD + index (optional, for inspection)
│
├─▶ odom.py lidar odometry ──▶ poses.npz
│ │
│ ├─▶ level_map.py gravity levelling ──▶ R_level.npy
│ ├─▶ georeference.py GPS → ENU (optional) ──▶ poses_geo.npz
│ └─▶ merge_map.py merge + filter ──▶ merged.pcd
│
└─▶ plot_trajectory.py trajectory figure (any TUM-format trajectory)
viewer/index.html browser point-cloud viewer
Indoor office session — merged cloud (left: top-down, floor-plan slice, vertical section)
and the trajectory figure produced by plot_trajectory.py:
git clone git@github.com:wheelos-tools/whl-scanner.git
cd whl-scanner
python3 -m venv .venv && ./.venv/bin/pip install -r requirements.txtNo ROS installation is needed — rosbags reads ROS 1 bags directly.
New to this? See QUICKSTART.md for a worked end-to-end run.
| Script | Purpose |
|---|---|
whl_scanner/bag2pcd.py |
Export each lidar sweep to its own PCD, plus a frames.csv index. Lossless; useful for inspection. |
whl_scanner/odom.py |
Lidar odometry. Scan-to-map VGICP with two-pass motion compensation → one 6-DOF pose per frame. |
whl_scanner/level_map.py |
Gravity levelling. Accelerometer + RANSAC ground-plane fit → a rotation making +Z true up. The only stage that uses the IMU. |
whl_scanner/georeference.py |
Optional. Rigidly aligns the trajectory to GNSS, producing an ENU frame with a documented lat/lon origin. |
whl_scanner/gps_tools.py |
GNSS helpers: read fixes, project to ENU, score a trajectory, elastic correction. |
whl_scanner/merge_map.py |
Merge all frames into one cloud: range/noise filtering, voxel dedup, isolated-speck removal. |
whl_scanner/plot_trajectory.py |
Plot any TUM-format trajectory, optionally over a cloud and against a reference. |
viewer/index.html |
Browser point-cloud viewer with height/axis filters and intensity colouring. |
Full field-level reference, in English and Simplified Chinese:
- docs/EXPORT_FORMAT.md · 中文 — baseline export
- docs/EXPORT_FORMAT_GPS.md · 中文 — with GPS enabled
- docs/SDK_REQUIREMENTS.md · 中文 — our open request to the vendor for a Linux localization SDK
Established by inspecting real sessions. Each one silently corrupts results if missed.
- There are no poses in the bag. Only
/livox/lidarand/livox/imu. Points are in the sensor frame of each sweep, not a world frame — consecutive frames differ by 4–12 cm while moving. Concatenating them without registration gives a smeared mess. - A "frame" is not a snapshot. Points within one message span ~100 ms
(
offset_time). At 50 °/s the scanner turns ~5° mid-sweep. Deskewing is not optional. - Deskew must use the current frame's motion. Driving it from the previous
frame's motion, inside a loop that also feeds the map, has loop gain > 1 and
diverges (~1.35×/frame).
odom.pyuses a two-pass scheme for this reason. - The lidar is mounted ~29–30° off vertical. Raw z is not height. Level with
level_map.pybefore interpreting anything vertical. - IMU acceleration is in g, not m/s². Gyro carries ~1.5 °/s of static bias, so
|gyro|never reads zero at rest. - Zero-range points are no-returns, not measurements: ~16% indoors, ~60% outdoors
(beams into open sky). Filter
range >= 0.2 m. - Indoor filter settings do not transfer outdoors. Outdoor scenes have far less
overlap (1.2× dedup vs 3.7× indoors), so an aggressive neighbour threshold deletes
real distant structure. See
--nb-radius/--min-neighbours. - GNSS is single-point, metre-level, 2D only — no orientation, no altitude. Good
for georeferencing and drift bounding; far too coarse to improve local accuracy.
Measured here: elastically fitting the trajectory to GPS degraded map sharpness by
7–18%, so
georeference.pydeliberately applies a rigid transform only.
Indoor office, 124 s, 1241 sweeps, 24.8M points:
| Metric | Result |
|---|---|
| Pose stability over 35 s stationary | 1.1 / 3.4 / 0.8 mm |
| Rotation rate vs IMU gyro (independent sensor) | correlation 0.94 |
| Loop closure over a 53 m walk | 28.3 cm (0.53%) |
| Ground-plane flatness | 15.6 mm — at the Mid-360's own range noise |
| Agreement with the vendor's own SLAM trajectory | mean 1.9 cm; path length within 0.2% |
viewer/ is derived from Fabulani/pcd-viewer
(MIT, © 2024 Fabiano Junior Maia Manschein), rewritten for multi-scene, Z-up,
gravity-levelled clouds. Original licence retained in viewer/LICENSE.

