slam-toolbox 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: slam_toolbox
3
+ Version: 0.1.0
4
+ Summary: A Python CLI tool for interactive SLAM map processing.
5
+ Author: FineNav
6
+ Requires-Dist: questionary>=2.0.0
7
+ Requires-Dist: numpy>=1.20.0
8
+ Requires-Dist: open3d>=0.15.0
9
+ Requires-Dist: pyyaml>=6.0
10
+ Requires-Dist: rich>=12.0.0
11
+ Dynamic: author
12
+ Dynamic: requires-dist
13
+ Dynamic: summary
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,21 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="slam_toolbox",
5
+ version="0.1.0",
6
+ packages=find_packages(),
7
+ install_requires=[
8
+ "questionary>=2.0.0",
9
+ "numpy>=1.20.0",
10
+ "open3d>=0.15.0",
11
+ "pyyaml>=6.0",
12
+ "rich>=12.0.0",
13
+ ],
14
+ entry_points={
15
+ "console_scripts": [
16
+ "slam_toolbox=slam_toolbox.cli:main",
17
+ ],
18
+ },
19
+ author="FineNav",
20
+ description="A Python CLI tool for interactive SLAM map processing.",
21
+ )
File without changes
@@ -0,0 +1,164 @@
1
+ import os
2
+ import numpy as np
3
+ import questionary
4
+ from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn, MofNCompleteColumn
5
+
6
+ from .extractor import _read_pcd, _write_pcd
7
+
8
+
9
+ BATCH_SIZE = 15 # 每批帧数,控制内存峰值
10
+
11
+
12
+ def _voxel_downsample(xyz, voxel_size, intensity=None):
13
+ """体素下采样,对 xyz 和 intensity 做均值聚合。"""
14
+
15
+ voxel_indices = np.floor(xyz / voxel_size).astype(np.int64)
16
+
17
+ # 用 structured array 做去重,无坐标范围限制
18
+ dtype = np.dtype([('i', np.int64), ('j', np.int64), ('k', np.int64)])
19
+ structured = np.empty(len(xyz), dtype=dtype)
20
+ structured['i'] = voxel_indices[:, 0]
21
+ structured['j'] = voxel_indices[:, 1]
22
+ structured['k'] = voxel_indices[:, 2]
23
+
24
+ _, inverse, counts = np.unique(structured, return_inverse=True, return_counts=True)
25
+ unique_count = counts.size
26
+
27
+ # 平均 xyz
28
+ sum_xyz = np.zeros((unique_count, 3), dtype=np.float64)
29
+ np.add.at(sum_xyz[:, 0], inverse, xyz[:, 0].astype(np.float64))
30
+ np.add.at(sum_xyz[:, 1], inverse, xyz[:, 1].astype(np.float64))
31
+ np.add.at(sum_xyz[:, 2], inverse, xyz[:, 2].astype(np.float64))
32
+ avg_xyz = (sum_xyz / counts[:, None]).astype(np.float32)
33
+
34
+ if intensity is not None:
35
+ sum_intensity = np.zeros(unique_count, dtype=np.float64)
36
+ np.add.at(sum_intensity, inverse, intensity.astype(np.float64))
37
+ avg_intensity = (sum_intensity / counts).astype(np.float32)
38
+ else:
39
+ avg_intensity = None
40
+
41
+ return avg_xyz, avg_intensity
42
+
43
+
44
+ def start_building(map_path):
45
+ frame_dir = os.path.join(map_path, "frame")
46
+ map_dir = os.path.join(map_path, "map")
47
+ os.makedirs(map_dir, exist_ok=True)
48
+
49
+ if not os.path.exists(frame_dir):
50
+ print(f"帧目录 {frame_dir} 不存在。请先运行 Frame Extractor 功能。")
51
+ return
52
+
53
+ files = sorted([f for f in os.listdir(frame_dir) if f.endswith(".pcd")])
54
+ if not files:
55
+ print("未在帧目录中找到 .pcd 文件。")
56
+ return
57
+
58
+ voxel_str = questionary.text("请输入体素下采样大小 (米):", default="0.05").ask()
59
+ try:
60
+ voxel_size = float(voxel_str)
61
+ except ValueError:
62
+ voxel_size = 0.05
63
+
64
+ # 检查是否有 intensity 数据
65
+ sample_xyz, sample_i = _read_pcd(os.path.join(frame_dir, files[0]))
66
+ has_intensity = sample_i is not None
67
+ print(f"正在分批建图(每 {BATCH_SIZE} 帧体素下采样, "
68
+ f"voxel={voxel_size}m, intensity={'✓' if has_intensity else '✗'})")
69
+
70
+ # 累积器(处理过下采样的中间结果)
71
+ acc_xyz = None # (M, 3)
72
+ acc_intensity = None # (M,) 或 None
73
+ total_batches = (len(files) + BATCH_SIZE - 1) // BATCH_SIZE
74
+
75
+ with Progress(
76
+ SpinnerColumn(),
77
+ TextColumn("[progress.description]{task.description}"),
78
+ BarColumn(),
79
+ MofNCompleteColumn(),
80
+ TimeElapsedColumn(),
81
+ ) as progress:
82
+ task = progress.add_task("处理点云帧...", total=len(files))
83
+
84
+ batch_xyz_list = []
85
+ batch_intensity_list = []
86
+
87
+ for i, file in enumerate(files):
88
+ pcd_path = os.path.join(frame_dir, file)
89
+ odom_path = pcd_path.replace(".pcd", ".odom")
90
+
91
+ xyz, intensity = _read_pcd(pcd_path)
92
+
93
+ if xyz is None or len(xyz) == 0:
94
+ progress.update(task, advance=1)
95
+ continue
96
+
97
+ # 应用 odom 位姿
98
+ if os.path.exists(odom_path):
99
+ try:
100
+ pose = np.loadtxt(odom_path)
101
+ if pose.shape == (4, 4):
102
+ n = len(xyz)
103
+ pts_h = np.ones((n, 4), dtype=np.float64)
104
+ pts_h[:, :3] = xyz
105
+ xyz = (pose @ pts_h.T).T[:, :3].astype(np.float32)
106
+ except Exception:
107
+ pass
108
+
109
+ batch_xyz_list.append(xyz)
110
+ if intensity is not None:
111
+ batch_intensity_list.append(intensity)
112
+
113
+ # 批次满:下采样后合并到累积器
114
+ if (i + 1) % BATCH_SIZE == 0:
115
+ batch_xyz = np.vstack(batch_xyz_list)
116
+ batch_i = (np.hstack(batch_intensity_list)
117
+ if batch_intensity_list else None)
118
+
119
+ ds_xyz, ds_i = _voxel_downsample(batch_xyz, voxel_size, batch_i)
120
+
121
+ if acc_xyz is None:
122
+ acc_xyz, acc_intensity = ds_xyz, ds_i
123
+ else:
124
+ # 合并到累积器再下采样,消除批次间重叠
125
+ acc_xyz = np.vstack([acc_xyz, ds_xyz])
126
+ acc_intensity = (np.hstack([acc_intensity, ds_i])
127
+ if acc_intensity is not None and ds_i is not None
128
+ else None)
129
+ acc_xyz, acc_intensity = _voxel_downsample(
130
+ acc_xyz, voxel_size, acc_intensity)
131
+
132
+ batch_xyz_list = []
133
+ batch_intensity_list = []
134
+
135
+ batch_num = (i + 1) // BATCH_SIZE
136
+ progress.update(task, advance=BATCH_SIZE,
137
+ description=f"处理点云帧... (批次 {batch_num}/{total_batches})")
138
+
139
+ # 处理剩余不足一个批次的帧
140
+ remaining = len(files) % BATCH_SIZE
141
+ if batch_xyz_list:
142
+ batch_xyz = np.vstack(batch_xyz_list)
143
+ batch_i = (np.hstack(batch_intensity_list)
144
+ if batch_intensity_list else None)
145
+ ds_xyz, ds_i = _voxel_downsample(batch_xyz, voxel_size, batch_i)
146
+
147
+ if acc_xyz is None:
148
+ acc_xyz, acc_intensity = ds_xyz, ds_i
149
+ else:
150
+ acc_xyz = np.vstack([acc_xyz, ds_xyz])
151
+ if acc_intensity is not None and ds_i is not None:
152
+ acc_intensity = np.hstack([acc_intensity, ds_i])
153
+ acc_xyz, acc_intensity = _voxel_downsample(
154
+ acc_xyz, voxel_size, acc_intensity)
155
+
156
+ progress.update(task, advance=remaining)
157
+
158
+ # 最终全局下采样
159
+ print("正在最终全局去重...")
160
+ final_xyz, final_intensity = _voxel_downsample(acc_xyz, voxel_size, acc_intensity)
161
+
162
+ output_pcd_path = os.path.join(map_dir, "map.pcd")
163
+ _write_pcd(output_pcd_path, final_xyz, final_intensity)
164
+ print(f"全局地图拼接完成 → {output_pcd_path}(共 {len(final_xyz):,} 点)")
@@ -0,0 +1,113 @@
1
+ import os
2
+ import sys
3
+ import questionary
4
+ from rich.console import Console
5
+
6
+ console = Console()
7
+
8
+ MAP_BASE_DIR = os.path.expanduser("~/Map")
9
+
10
+ def get_map_directories():
11
+ """扫描 ~/Map 目录下所有包含或可能用于存放地图的文件夹"""
12
+ os.makedirs(MAP_BASE_DIR, exist_ok=True)
13
+ dirs = [d for d in os.listdir(MAP_BASE_DIR) if os.path.isdir(os.path.join(MAP_BASE_DIR, d))]
14
+ # 过滤掉隐藏文件夹
15
+ return [d for d in dirs if not d.startswith('.')]
16
+
17
+ def main():
18
+ try:
19
+ import rclpy
20
+ except ImportError:
21
+ console.print("[red]错误: 未检测到 ROS2 环境。请先 source 您的 ROS2 工作空间再运行此工具。[/red]")
22
+ sys.exit(1)
23
+
24
+ console.print("[bold green]欢迎使用 SLAM Toolbox CLI[/bold green]\n")
25
+
26
+ # 1. 选择工作地图目录
27
+ dirs = get_map_directories()
28
+
29
+ NEW_MAP_TOKEN = "新建地图"
30
+
31
+ while True:
32
+ choices = dirs + [NEW_MAP_TOKEN] if dirs else [NEW_MAP_TOKEN]
33
+ map_name = questionary.select(
34
+ "请选择需要操作的地图目录:",
35
+ choices=choices
36
+ ).ask()
37
+
38
+ if not map_name:
39
+ return
40
+
41
+ if map_name == NEW_MAP_TOKEN:
42
+ new_name = questionary.text("请输入新地图名称:").ask()
43
+ if not new_name:
44
+ continue
45
+ os.makedirs(os.path.join(MAP_BASE_DIR, new_name, "bag"), exist_ok=True)
46
+ os.makedirs(os.path.join(MAP_BASE_DIR, new_name, "map"), exist_ok=True)
47
+ dirs = get_map_directories() # 刷新列表
48
+ map_name = new_name
49
+ break
50
+ else:
51
+ break
52
+
53
+ # 设置常用路径
54
+ map_path = os.path.abspath(os.path.join(MAP_BASE_DIR, map_name))
55
+
56
+ # 2. 主功能循环
57
+ while True:
58
+ action = questionary.select(
59
+ f"当前地图: {map_name} | 请选择操作类型:",
60
+ choices=[
61
+ "1. 3D Map",
62
+ "2. 2D Map",
63
+ "退出"
64
+ ]
65
+ ).ask()
66
+
67
+ if action == "1. 3D Map":
68
+ sub_action = questionary.select(
69
+ "3D Map 子功能列表:",
70
+ choices=[
71
+ "1. Bag Recorder (录制 Bag 包)",
72
+ "2. Frame Extractor (帧提取)",
73
+ "3. Map Builder (点云地图构建)",
74
+ "4. ERASOR2 (动态障碍物去除)",
75
+ "5. Removert (动态障碍物去除)",
76
+ "返回上一级"
77
+ ]
78
+ ).ask()
79
+
80
+ if "1. Bag Recorder" in sub_action:
81
+ from .recorder import start_recording
82
+ start_recording(map_path)
83
+ elif "2. Frame Extractor" in sub_action:
84
+ from .extractor import start_extraction
85
+ start_extraction(map_path)
86
+ elif "3. Map Builder" in sub_action:
87
+ from .builder import start_building
88
+ start_building(map_path)
89
+ elif "4. ERASOR2" in sub_action:
90
+ from .dynamic_removal import start_erasor2
91
+ start_erasor2(map_path)
92
+ elif "5. Removert" in sub_action:
93
+ from .dynamic_removal import start_removert
94
+ start_removert(map_path)
95
+
96
+ elif action == "2. 2D Map":
97
+ sub_action = questionary.select(
98
+ "2D Map 子功能列表:",
99
+ choices=[
100
+ "1. PGM Generator (生成 2D 栅格地图)",
101
+ "返回上一级"
102
+ ]
103
+ ).ask()
104
+
105
+ if "1. PGM Generator" in sub_action:
106
+ from .pgm_generator import start_generation
107
+ start_generation(map_path)
108
+
109
+ else:
110
+ break
111
+
112
+ if __name__ == "__main__":
113
+ main()