# G1 SLAM Stack

Production SLAM for **Unitree G1 Edu** humanoid + **Livox MID-360** head-mounted LiDAR.

KISS-ICP scan matching, voxel persistence filtering, geometric place recognition, pose-graph loop closure, submap mapping with atomic checkpointing, and a Nav2-compatible map export pipeline. Runs as a multi-process worker with a PyQt commander UI.

---

## Hardware

| Item | Spec |
|---|---|
| Robot | Unitree G1 Edu (~1.32 m, ~0.30 m stride) |
| LiDAR | Livox MID-360 (head-mounted, z ≈ 1.30 m) |
| LiDAR rate | 10 Hz, ~200 K pts/s, 360° H-FoV, -7° to +52° V-FoV |
| LiDAR range | 0.10 m – 40 m (10% reflectivity) |
| Compute | Workstation (mapping/UI) + Jetson Orin NX (deploy target) |

---

## Requirements

### System

- Linux x86_64 (workstation) or aarch64 (Jetson Orin NX)
- Python ≥ 3.10
- A wired NIC (Gigabit) on the LiDAR network — typically `enp3s0`

### Native libraries

- **Livox-SDK2** — C++ SDK from Livox. The repo ships with `Livox-SDK2/` as a submodule. Build and install:
  ```bash
  cd Livox-SDK2
  mkdir -p build && cd build
  cmake .. && make -j
  sudo make install
  ```
  This places `liblivox_lidar_sdk_*` under `/usr/local/lib/`.

### Python packages

```bash
pip install numpy scipy open3d kiss-icp PyQt6 pyqtgraph
```

| Package | Why |
|---|---|
| `numpy` | Point-cloud math throughout |
| `scipy` | KD-trees in cleanup / loop closure |
| `open3d` | Point-cloud I/O (.ply read/write) |
| `kiss-icp` | Scan-to-scan ICP backend |
| `PyQt6` | Commander GUI |
| `pyqtgraph` | OpenGL viewport in the GUI |

The Livox driver wrapper (`livox2_python.py`) uses `ctypes` against the installed `liblivox_lidar_sdk_*` — no separate Python binding required.

### Network

LiDAR ships at `192.168.123.120`. The workstation NIC must be on the same `/24`:

```bash
sudo ip addr add 192.168.123.222/24 dev enp3s0
sudo ip link set enp3s0 up
ping 192.168.123.120   # verify
```

UDP ports 56100–56501 must not be in use (close LivoxViewer 2 / other SLAM instances first).

---

## How to run

### 1. Verify the LiDAR is online

```bash
ping 192.168.123.120
```

If no reply: check cable, NIC IP (`ip addr show enp3s0`), and that no other software is bound to the LiDAR's ports.

### 2. Sanity-check the config

Open `SLAM_Config.json` and `mid360_config.json`. Confirm:
- `network.default_host_ip` matches your NIC IP
- `mid360_config.json` host IPs match
- `extrinsic_parameter.z` matches the actual LiDAR mount height

### 3. Launch the GUI

```bash
cd /home/zedx/Robotics_workspace/yslootahtech/G1_Lootah/Lidar
python3 SLAM_GUI.py
```

The **SLAM Commander** window opens.

### 4. Map a new place

1. Click **CONNECT** — the worker spins up and connects to the LiDAR.
2. Click **MAP NEW PLACE** — sets profile to `MAP_NEW` (long decay, fine voxels).
3. Click **START** — mapping begins; points appear in the viewport.
4. Walk the robot through the space.
5. Click **STOP** — if `SAVE ARMED` is checked, the map saves to `DataMap/<name>.ply`.

Use **AUTOSAVE** for long sessions (saves every N seconds).

### 5. Localize / navigate in an existing map

1. **CONNECT**.
2. **NAVIGATE IN MAPPED PLACE** — sets profile to `LOCALIZE_MAP`.
3. **LOAD MAP** — pick a `.ply` from `DataMap/`.
4. **START** — the system runs ICP against the loaded map. The status bar shows `TRACKING` once aligned.
5. Use the mission/waypoint controls to drive goals.

If the robot starts in an unknown spot inside the map, global relocalization runs automatically: session-memory cache → place recognition → brute-force anchor × yaw search.

### 6. Override the config path

```bash
SLAM_CONFIG=/path/to/custom.json python3 SLAM_GUI.py
```

### 7. Headless replay / regression

```bash
python3 SLAM_Replay.py --recording <path>.lvx2
```

---

## File map

| File | Purpose |
|---|---|
| `SLAM_GUI.py` | PyQt commander UI |
| `SLAM_engine.py` | Config dataclasses, worker bootstrap, `EngineConfig` |
| `SLAM_worker.py` | Main SLAM loop: ingest, ICP, filter, map, checkpoint, save, navigate |
| `SLAM_LocalizationService.py` | `odom`/`map`/`ref` frame transforms |
| `SLAM_LoopClosure.py` | Keyframe loop closure with SE3-slerp error distribution |
| `SLAM_PlaceRecognition.py` | Geometric anchor descriptors for global relocalization |
| `SLAM_StateMachine.py` | TRACKING / DEGRADED / LOST / RECOVERY |
| `SLAM_Submap.py` | Local + global submap mapper, atomic `.pkl` checkpointing |
| `SLAM_Filter.py` | Voxel persistence filter + indoor map quality filter |
| `SLAM_MAP.py` | Stable-map layer, `.ply` export via Open3D |
| `SLAM_Navigation.py` | Nav2-compatible YAML/PGM export, A* planner |
| `SLAM_NavRuntime.py` | Live cost-map for runtime planning |
| `SLAM_Mission.py` | Waypoint missions |
| `SLAM_Fusion.py` | LiDAR + IMU + (wheel/vision) pose fusion |
| `SLAM_Safety.py` | E-stop / stale-localization watchdog |
| `SLAM_Session.py` | Session memory (cached `slam→ref` alignments per map) |
| `SLAM_Replay.py` | Offline replay / regression harness |
| `SLAM_Validation.py` | Startup self-check |
| `SLAM_Diagnostics.py` | Crash logging, performance snapshots |
| `SLAM_Transforms.py` | SE3 utilities, pose deltas, slerp |
| `livox2_python.py` | Livox-SDK2 ctypes wrapper, with tag-byte noise filter |
| `SLAM_Config.json` | Single source of truth for tuning |
| `mid360_config.json` | Livox driver config (IPs, ports, extrinsics) |

---

## Configuration reference

### Network — IPs & Interfaces

| Source | Setting | Value |
|---|---|---|
| `SLAM_Config.json` | `network.default_interface` | `enp3s0` |
| `SLAM_Config.json` | `network.default_host_ip` | `192.168.123.222` (workstation) |
| `mid360_config.json` | `lidar_configs[0].ip` | `192.168.123.120` (LiDAR — Unitree default) |
| `mid360_config.json` | `host_net_info.*_ip` | `192.168.123.222` (matches workstation) |

> Reference (Unitree docs): default LiDAR IP `192.168.123.120`, host on `192.168.123.x/24`, gateway `192.168.123.1`.

### LiDAR UDP ports — `mid360_config.json`

| Direction | Channel | LiDAR side | Host side |
|---|---|---|---|
| Control | `cmd_data` | 56100 | 56101 |
| Push messages | `push_msg` | 56200 | 56201 |
| Point cloud | `point_data` | 56300 | 56301 |
| IMU data | `imu_data` | 56400 | 56401 |
| Logs | `log_data` | 56500 | 56501 |

### LiDAR extrinsics — `mid360_config.json` (head mount, G1)

| Param | Value | Note |
|---|---|---|
| `roll` | 0.0° | |
| `pitch` | 0.0° | head mount, level (Unitree default) |
| `yaw` | 0.0° | |
| `x` | 0.0 m | |
| `y` | 0.0 m | |
| `z` | 1.30 m | LiDAR atop head, ≈ floor + 1.30 m |
| `pcl_data_type` | 1 | Cartesian High |
| `pattern_mode` | 0 | non-repetitive |

### SLAM core tuning — `SLAM_Config.json` highlights

| Section | Key | Value | Purpose |
|---|---|---|---|
| **slam** | `slam_voxel_size` | 0.12 m | ICP voxel |
| | `max_range` | 50.0 m | LiDAR clip |
| **filter** | `voxel_size` | 0.20 m | persistence base |
| | `hits_threshold` | 4 | (overridden by profiles) |
| | `persistence.decay_seconds` | 3.0 s | (overridden by profiles) |
| | `persistence.max_voxels` | 2,000,000 | mem cap |
| **map** | `display_voxel` / `save_voxel` | 0.08 / 0.05 m | GUI / .ply resolution |
| | `min_points_to_save` | 550 | guard |
| **map_quality** | `near_min_range_m` | 0.15 m | hardware min is 0.10 |
| | `body_exclusion` | x[-0.20, 0.25] · y[±0.25] · z[-1.40, -0.10] | head-mount G1 body box |
| **map_cleanup** | `keep_largest_n` / `period_sec` | 2 / 6.0 s | islands removed every 6 s |
| **submap_mapping** | `local_voxel_m` / `global_voxel_m` | 0.08 / 0.14 m | |
| | `checkpoint_interval_sec` | 60.0 s | atomic .pkl save |
| **navigation_export** | `z_min_m` / `z_max_m` | 0.05 / 1.40 m | floor+5 cm to 1.40 m |
| | `resolution_m` | 0.05 m | Nav2 grid |
| **state_machine** | `min_good_to_recover` | 3 | RECOVERY → TRACKING |
| **mission** | `waypoint_tolerance_m` | 0.55 m | G1 ~0.30 m stride margin |
| **safety** | `stop_radius_m` | 0.50 m | |
| | `stale_localization_sec` | 1.5 s | |
| **fusion** | `enabled` | `true` | LiDAR + IMU pose fusion |
| | `imu_weight` / `lidar_weight` | 0.25 / 1.0 | |
| **runtime** | `publish_hz` | 12.0 | GUI/status update rate |
| **livox** | `tag_filter` | `true` | Unitree-recommended noise drop |
| **loop_closure** | `enabled` | `false` | optimizer is real, but disabled |

### Stability profiles — hardcoded in `SLAM_worker.py:1957-1961`

| Profile | hit_threshold | decay_seconds | voxel_size | density |
|---|---|---|---|---|
| `MAP_NEW` | 2 | 1800.0 | 0.10 | MEDIUM |
| `LOCALIZE_MAP` | 3 | 45.0 | 0.20 | MEDIUM |
| `LIVE_NAV_MAP` | 2 | 18.0 | 0.20 | MEDIUM |
| `LIVE_NAV_NO_MAP` | 2 | 14.0 | 0.18 | HIGH |
| `QUICK_DEMO` | 2 | 8.0 | 0.20 | HIGH |
| `BALANCED` | (from config) | (from config) | (from config) | MEDIUM |

### File locations

```
G1_Lootah/Lidar/
├── SLAM_Config.json          # main config (single source of truth)
├── mid360_config.json        # Livox driver (read by SDK2)
└── DataMap/                  # maps + submap_checkpoint.pkl + session memory
```

Override path: `SLAM_CONFIG=/path/to/other.json`.

---

## Workflows

| Workflow | Profile | When to use |
|---|---|---|
| Map a new place | `MAP_NEW` | Building a fresh map. Long decay (30 min), 0.10 m voxels. |
| Navigate in mapped place | `LOCALIZE_MAP` | Localize against an existing `.ply`. |
| Live nav with map | `LIVE_NAV_MAP` | Navigation + slow map updates. |
| Live nav (no map) | `LIVE_NAV_NO_MAP` | Pure obstacle-avoidance, no persistent map. |
| Quick demo | `QUICK_DEMO` | Short-window mapping for demos. |

The current profile shows in the GUI status bar.

---

## Outputs

```
DataMap/
├── *.ply                       # saved point cloud maps
├── *.yaml + *.pgm              # Nav2-compatible map_server bundles
├── submap_checkpoint.pkl       # atomic submap snapshot (auto-restored on next start)
└── SLAM_session_memory.json    # cached relocalization alignments per map
```

---

## Frame conventions

- World z = 0 is floor.
- LiDAR origin at sensor frame's z = 0; extrinsic places it at body-frame z ≈ 1.30 m.
- Body exclusion box in **sensor frame**: z = -1.40 (floor) to z = -0.10 (just below LiDAR).
- ICP yields `T_world_lidar`; localization service maintains `odom_to_map` and `odom_to_ref` separately.

---

## Deploy

Edit on the **workstation only**. Push to Jetson via `scp`:

```bash
scp -r /home/zedx/Robotics_workspace/yslootahtech/G1_Lootah/Lidar/ \
    unitree@<jetson-ip>:~/G1_Lootah/
```

Do not edit on the Jetson directly.

---

## Common issues

- **Connect fails / `Address already in use`** — another Livox app holds ports 56101–56501. Close LivoxViewer 2 / other SLAM instances.
- **No map / empty viewport** — verify `ping 192.168.123.120` works, NIC IP matches `default_host_ip`, and the LiDAR LED is solid.
- **Map ignores furniture** — head-mount + -7° lower FoV creates a blind cone underneath; close-floor area is unscannable < ~10 m. Move the robot closer or accept the limitation.
- **Stray points in saved map** — cleanup pass runs every 6 s and once more on save; if islands persist, lower `map_cleanup.keep_largest_n`.
- **Loop closure off** — set `loop_closure.enabled: true` in config; the optimizer (`SLAM_LoopClosure._optimize`) is fully wired.
- **`SDKINIT failed`** — Livox-SDK2 not installed; rebuild `Livox-SDK2/` and `sudo make install`.
