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.
- slam_toolbox-0.1.0/PKG-INFO +13 -0
- slam_toolbox-0.1.0/pyproject.toml +3 -0
- slam_toolbox-0.1.0/setup.cfg +4 -0
- slam_toolbox-0.1.0/setup.py +21 -0
- slam_toolbox-0.1.0/slam_toolbox/__init__.py +0 -0
- slam_toolbox-0.1.0/slam_toolbox/builder.py +164 -0
- slam_toolbox-0.1.0/slam_toolbox/cli.py +113 -0
- slam_toolbox-0.1.0/slam_toolbox/dynamic_removal.py +637 -0
- slam_toolbox-0.1.0/slam_toolbox/extractor.py +428 -0
- slam_toolbox-0.1.0/slam_toolbox/pgm_generator.py +96 -0
- slam_toolbox-0.1.0/slam_toolbox/recorder.py +107 -0
- slam_toolbox-0.1.0/slam_toolbox.egg-info/PKG-INFO +13 -0
- slam_toolbox-0.1.0/slam_toolbox.egg-info/SOURCES.txt +15 -0
- slam_toolbox-0.1.0/slam_toolbox.egg-info/dependency_links.txt +1 -0
- slam_toolbox-0.1.0/slam_toolbox.egg-info/entry_points.txt +2 -0
- slam_toolbox-0.1.0/slam_toolbox.egg-info/requires.txt +5 -0
- slam_toolbox-0.1.0/slam_toolbox.egg-info/top_level.txt +1 -0
|
@@ -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,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()
|