Построение карты проходимости на GPU по данным RGB-D камеры & лидара
После полевых испытаний карты проходимости, о которой шла речь в предыдущей статье, обнаружились баги: краши программы и проблемы визуализатора. Пока я их исправлял, на карте заметил странное поведение: на ровной поверхности периодически появлялись длинные цветные полосы, которые алгоритм интерпретировал как перепад высот.
Теоретически такой перепад мог возникнуть в трёх случаях:
Отсканированная поверхность действительно находится под наклоном.
В калибровке камеры есть ошибки, из-за чего сканы не стыкуются в одной плоскости.
Есть скачки одометрии.
Дорога на испытаниях действительно имела уклоны — квадроцикл легко катился под горку. Но главная причина оказалась в самой природе дискретной воксельной карты.
Почему воксели 10 см плохо дружат с уклонами
Воксель усредняет измерения и накапливает их внутри своего объёма. Пока наклон поверхности не превышает некоторый порог, карта хранит усреднённое значение. Как только порог пройден, происходит резкий скачок: поверхность на карте «подпрыгивает» на один куб вверх или вниз.
Для наземного робота это фундаментальная проблема. Воксель размером 10 см — это фактически куб 10×10×10 см. Такой куб ровер ещё может переехать на небольшой скорости. Но два куба подряд — это уже высота около половины радиуса колеса. Без достаточного разгона ровер просто отскочит от него.
В итоге планировщик получает всего три дискретных состояния:
ровно — можно ехать быстро;
неровно — нужно ехать медленно и аккуратно;
непроходимо — нужно объезжать.
На практике это сильно ограничивает планирование. Небольшие препятствия размером в один куб можно объехать или медленно переехать. Два куба в высоту уже разумно считать полностью непроходимыми: залетать на них на полном ходу — риск переворота и повреждения ровера.
Отдельный вопрос — наклонные поверхности. На них перепады почти неизбежны, потому что карта накапливает измерения внутри объёма вокселя и в какой-то момент делает резкий скачок на куб вверх или вниз. Если пытаться решить это только уменьшением размера вокселя, мы упираемся в другие ограничения.
Уменьшение разрешения с 10 см до 5 см и меньше даёт больше градаций: вместо трёх состояний появляется, условно, пять. Минимальный порог уклона становится 5 см, а всё, что выше, уже считается препятствием. Но плата за это велика:
карта требует в два раза больше памяти;
планировщик state lattice, взятый из ROS, увеличивает время планирования примерно в четыре раза;
уменьшается максимальный радиус поворота ровера, который и так ограничен 3,9 м при 24 направления движения.
Почему так? Примитивы state lattice должны точно сходиться в решётке генератора и их размер привязан к её сетке. Если уменьшить ячейку, они перестанут сходиться, генератор выдаст ошибку или уйдёт в бесконечный цикл. Поэтому для больших машин нужны большие ячейки на карте.
У гибридного A* таких жёстких ограничений нет: там можно задать любой радиус поворота даже на маленькой ячейке. Но платой становится резкий рост времени планирования, потому что в каждой ячейке раскрывается множество направлений движения, а не фиксированный набор примитивов. С увеличением числа ячеек количество направлений и время растёт в разы.
Получается, что уменьшение размера вокселя — палка о двух концах: с одной стороны, карта становится подробнее, с другой — растёт нагрузка на всю систему обхода препятствий.
Идея: хранить не занятость, а высоту
Более логичный путь — вспомнить, что воксельная карта в итоге конвертируется в карту высот. Кроме того, она стабилизирована в пространстве: её оси привязаны к системе координат земли и вектору гравитации.
Это значит, что мы можем увеличить разрешение карты до максимального, которое дают сенсоры не уменьшая размер сетки. Для этого нужно после перевода точек в систему координат карты записывать в ячейку не бинарное состояние 1/0, а точную высоту точки. Тогда карта перестанет быть дискретной по высоте и получит максимально возможное разрешение сенсора.
Для лидара заявленная точность — 1–2 см. Для RGB-D камеры точность зависит от расстояния: в пределах 1–1,5 м это около ±2 мм, на 5 м — уже ±10 см, дальше ещё хуже.
Если использовать сырые данные, как я делал раньше, рост разрешения проявит шум сенсора, в то-же время какие-то простые фильтры в движении на роботе не работают или не дают должного эффекта.
Тем не менее у новой карты есть и плюсы:
миллиметровое разрешение позволяет строить карту нормалей, нормали важны для моделей динамики MPPI и MPC: они позволяют точнее рассчитывать силы, действующие на колёса, учитывать подъёмы, спуски и влияние гравитации в момент их преодоления и всё такое;
проблема перепадов исчезает: небольшой уклон плавно размазывается по карте и тонет в шуме измерений;
оценка проходимости становится точнее и плавнее: робот может оценить реальную высоту препятствия и решить, объезжать его или переехать без лишнего снижения скорости.
Плюсы очевидно перевешивали минусы, и я приступил к очередному апгрейду карты.
Реализация: frustum, центроид и atomicMin
Первая проблема на пути от сенсора к карте — вокселизация. Мой старый код мне не нравился и раньше, но для грубой карты особых усложнений не требовалось. Если во внутренний объём вокселя попадала хотя бы одна точка, он считался занятым. Такой карте абсолютно всё равно какая это будет точка и потому она была иммунна к гонкам данных, все потоки писали одно и то же значение — 1.
__global__
void kernel_deproject_depth_cuda(uint16_t *ray_points, const rs2_intrinsics* intrin, const uint16_t * depth, float depth_scale, double w, double x, double y, double z)
{
int i = blockDim.x * blockIdx.x + threadIdx.x;
if (i >= (*intrin).height * (*intrin).width) {
return;
}
int stride = blockDim.x * gridDim.x;
int a, b;
const float ww = w * w;
const float xx = x * x;
const float yy = y * y;
const float zz = z * z;
const float wx = w * x;
const float wy = w * y;
const float wz = w * z;
const float xy = x * y;
const float xz = x * z;
const float yz = y * z;
const float two = 2.0f;
for (int j = i; j < (*intrin).height * (*intrin).width; j += stride) {
b = j / (*intrin).width;
a = j - b * (*intrin).width;
const float pixel[] = { (float)a, (float)b };
float point[3] = {0}; float point_s[3] = {0}; uint16_t points[3] = {0};
deproject_pixel_to_point_cuda(point, intrin, pixel, depth_scale * depth[j]);
point_s[0] = (Rotation_matrix[0] * point[0] + Rotation_matrix[3] * point[1] + Rotation_matrix[6] * point[2]);
point_s[1] = (Rotation_matrix[1] * point[0] + Rotation_matrix[4] * point[1] + Rotation_matrix[7] * point[2]);
point_s[2] = (Rotation_matrix[2] * point[0] + Rotation_matrix[5] * point[1] + Rotation_matrix[8] * point[2]);
point_s[0] += Translation[0];
point_s[1] += Translation[1];
point_s[2] += Translation[2];
point_s[0] *= Map_scale;
point_s[1] *= Map_scale;
point_s[2] *= Map_scale;
points[0] = __float2int_rd(ww*point_s[0] + two*wy*point_s[2] - two*wz*point_s[1] + xx*point_s[0] + two*xy*point_s[1] + two*xz*point_s[2] - zz*point_s[0] - yy*point_s[0]) + Map_center_X;
points[1] = __float2int_rd(two*xy*point_s[0] + yy*point_s[1] + two*yz*point_s[2] + two*wz*point_s[0] - zz*point_s[1] + ww*point_s[1] - two*wx*point_s[2] - xx*point_s[1]) + Map_center_Y;
points[2] = __float2int_rd(two*xz*point_s[0] + two*yz*point_s[1] + zz*point_s[2] - two*wy*point_s[0] - yy*point_s[2] + two*wx*point_s[1] - xx*point_s[2] + ww*point_s[2]) + Map_center_Z;
if((points[0] < Map_X) && (points[1] < Map_Y) && (points[2] < Map_Z) && (points[0] > 0) && (points[1] > 0) && (points[2] > 0)) {
ray_points[j * 3] = points[0];
ray_points[1 + j * 3] = points[1];
ray_points[2 + j * 3] = points[2];
}
else {
ray_points[j * 3] = 0;
ray_points[1 + j * 3] = 0;
ray_points[2 + j * 3] = 0;
}
}
}
__global__
void voxelization_cuda( uint8_t * voxels, const uint16_t * ray_points, const unsigned int counter)
{
int i = blockDim.x * blockIdx.x + threadIdx.x;
if (i >= counter) {
return;
}
int stride = blockDim.x * gridDim.x;
for (int j = i; j < counter; j += stride) {
if((ray_points[j * 3] > 0) && (ray_points[1 + j * 3] > 0) && (ray_points[2 + j * 3] > 0)) {
voxels[get_value(ray_points[j * 3], ray_points[1 + j * 3], ray_points[2 + j * 3])] = 1;
}
}
}
Теперь на вход идут данные о высоте точки. За один воксель могут конкурировать сотни пикселей: объём вокселя 10×10×10 см, а карта глубины имеет размер 848×480, то есть 407 040 точек при FOV камеры 90° по ширине и 58° по высоте.
Для сравнения: у лидара всего около 20 000 точек на 360° по ширине и 57° по высоте. Даже там LIO-пакеты используют вокселизацию или downsampling, не обрабатывая каждый раз все точки напрямую. Корреляторы на CPU этого не тянут и не укладываются в realtime.
Ещё одна проблема вокселизации на GPU — динамическая аллокация. Её не любят, потому что она снижает производительность и заставляет ядро в конце опрашивать всю память, чтобы собрать массивы по кусочкам. Чтобы избежать этого, я просто аллоцировал ray_points максимальным размером, который он мог принять: 407 040 точек.
Но в реальности столько не нужно: один пиксель далеко не всегда проецируется в отдельный воксель. Точный размер зависит от площади видимой поверхности. Чем она больше, тем по большему числу вокселей распределятся точки. На это влияют форма объектов и расстояние до них. Сложные формы увеличивают видимую площадь, а также дистанции, заставляет пиксели распредялятся по большей площади.
Как выбрать оптимальный размер ray_points, решить гонку данных и выбрать, высоту какого из сотен конкурирующих пикселей записать в ячейку?
Для этого я построил модель FOV камеры — frustum view. Это поле зрения камеры в виде усечённой пирамиды, расширяющейся в сторону взгляда. Если заполнить внутренний объём этой пирамиды вокселями 10×10×10 см, получим оптимальный размер массива ray_points.
Остаётся выбрать, какой пиксель туда запишется. Для каждого вокселя считается его геометрический центр — центроид. Затем выбирается точка, ближайшая к центроиду. Так в каждый воксель попадает высота только одного, самого близкого к центроиду пикселя. Это заодно частично фильтрует выбросы карты глубины: в пределах объёма одного вокселя выбирается наименее шумящая точка.

Чтобы избежать гонки данных, используются атомарные операции. Они дорогие, поэтому я сократил количество их вызовов: упаковал три координаты точки как младшие байты, а дистанцию — как старшие, чтобы сравнение шло в первую очередь по ней, в одну 64-битную переменную unsigned long long с помощью сдвигов. Это как раз четыре обычные 16-битные переменные.
__global__
void kernel_deproject_depth_cuda(unsigned long long *ray_points_cam,
const rs2_intrinsics* intrin,
const uint16_t * depth,
float depth_scale)
{
int i = blockDim.x * blockIdx.x + threadIdx.x;
if (i >= (*intrin).height * (*intrin).width) return;
int stride = blockDim.x * gridDim.x;
for (int j = i; j < (*intrin).height * (*intrin).width; j += stride) {
int b = j / (*intrin).width;
int a = j - b * (*intrin).width;
const float pixel[] = { (float)a, (float)b };
float point[3] = {0};
deproject_pixel_to_point_cuda(point, intrin, pixel, depth_scale * depth[j]);
float xf = point[0] * 1000.0f;
float yf = point[1] * 1000.0f;
float zf = point[2] * 1000.0f;
if (zf < c_cam_near_mm || zf >= c_cam_far_mm) continue;
int iz = (int)((zf - c_cam_near_mm) / FRUSTUM_VOXEL_MM);
if (iz < 0 || iz >= c_Nz) continue;
float z_center = c_cam_near_mm + iz * FRUSTUM_VOXEL_MM + FRUSTUM_VOXEL_MM * 0.5f;
float half_x = z_center * c_tan_half_h;
float half_y = z_center * c_tan_half_v;
if (xf < -half_x || xf >= half_x) continue;
if (yf < -half_y || yf >= half_y) continue;
int Nx = c_Nx_per_layer[iz];
int Ny = c_Ny_per_layer[iz];
int ix = (int)((xf + half_x) / FRUSTUM_VOXEL_MM);
int iy = (int)((yf + half_y) / FRUSTUM_VOXEL_MM);
if (ix < 0 || ix >= Nx) continue;
if (iy < 0 || iy >= Ny) continue;
int16_t x_mm = (int16_t)xf;
int16_t y_mm = (int16_t)yf;
int16_t z_mm = (int16_t)zf;
// Центроид вокселя
float cx = -half_x + (ix + 0.5f) * FRUSTUM_VOXEL_MM;
float cy = -half_y + (iy + 0.5f) * FRUSTUM_VOXEL_MM;
float cz = z_center;
float dxn = xf - cx, dyn = yf - cy, dzn = zf - cz;
uint32_t d_new = (uint32_t)(dxn*dxn + dyn*dyn + dzn*dzn);
// Клампим dist² в 16 бит
if (d_new > 0xFFFFu) d_new = 0xFFFFu;
// Сдвигаем координаты в [0, 65535] (для беззнакового сравнения в atomicMin)
uint64_t ux = (uint64_t)((int32_t)x_mm + 32768);
uint64_t uy = (uint64_t)((int32_t)y_mm + 32768);
uint64_t uz = (uint64_t)((int32_t)z_mm + 32768);
// Упаковка: [63:48] dist² | [47:32] x | [31:16] y | [15:0] z
unsigned long long packed = ((unsigned long long)d_new << 48)
| ((unsigned long long)ux << 32)
| ((unsigned long long)uy << 16)
| (unsigned long long)uz;
uint32_t voxel_idx = get_value_frustum((uint16_t)iz, (uint16_t)iy, (uint16_t)ix);
atomicMin(&ray_points_cam[voxel_idx], packed);
}
}
__global__
void rotate_ray_points_to_world(const unsigned long long *ray_points_cam,
uint16_t *ray_points_world,
const unsigned int counter,
double w, double x, double y, double z)
{
int i = blockDim.x * blockIdx.x + threadIdx.x;
if (i >= counter) return;
int stride = blockDim.x * gridDim.x;
const float ww=w*w, xx=x*x, yy=y*y, zz=z*z;
const float wx=w*x, wy=w*y, wz=w*z;
const float xy=x*y, xz=x*z, yz=y*z;
const float two = 2.0f;
const unsigned long long MAX_PACKED = 0xFFFFFFFFFFFFFFFFULL;
for (int j = i; j < counter; j += stride) {
unsigned long long packed = ray_points_cam[j];
// Если буфер не перезаписан — пусто
if (packed == MAX_PACKED) {
ray_points_world[j*3] = 0;
ray_points_world[1 + j*3] = 0;
ray_points_world[2 + j*3] = 0;
continue;
}
// Распаковка: [63:48] dist² | [47:32] x | [31:16] y | [15:0] z
unsigned long long ux = (packed >> 32) & 0xFFFFULL;
unsigned long long uy = (packed >> 16) & 0xFFFFULL;
unsigned long long uz = packed & 0xFFFFULL;
int16_t cx = (int16_t)((int32_t)ux - 32768);
int16_t cy = (int16_t)((int32_t)uy - 32768);
int16_t cz = (int16_t)((int32_t)uz - 32768);
if (cz == 0) {
ray_points_world[j*3] = 0;
ray_points_world[1 + j*3] = 0;
ray_points_world[2 + j*3] = 0;
continue;
}
// мм → м
float px = cx * 0.001f;
float py = cy * 0.001f;
float pz = cz * 0.001f;
// 1. Калибровка камеры
float ps0 = Rotation_matrix[0]*px + Rotation_matrix[3]*py + Rotation_matrix[6]*pz;
float ps1 = Rotation_matrix[1]*px + Rotation_matrix[4]*py + Rotation_matrix[7]*pz;
float ps2 = Rotation_matrix[2]*px + Rotation_matrix[5]*py + Rotation_matrix[8]*pz;
// 2. Offset камеры
ps0 += Translation[0];
ps1 += Translation[1];
ps2 += Translation[2];
// 3. Масштаб
ps0 *= Map_scale;
ps1 *= Map_scale;
ps2 *= Map_scale;
// 4. Поворот кватернионом
float rs0 = ww*ps0 + two*wy*ps2 - two*wz*ps1 + xx*ps0 + two*xy*ps1 + two*xz*ps2 - zz*ps0 - yy*ps0;
float rs1 = two*xy*ps0 + yy*ps1 + two*yz*ps2 + two*wz*ps0 - zz*ps1 + ww*ps1 - two*wx*ps2 - xx*ps1;
float rs2 = two*xz*ps0 + two*yz*ps1 + zz*ps2 - two*wy*ps0 - yy*ps2 + two*wx*ps1 - xx*ps2 + ww*ps2;
// 5. В индексы карты
int imx = __float2int_rd(rs0 + Map_center_X);
int imy = __float2int_rd(rs1 + Map_center_Y);
int imz = __float2int_rd(rs2 + Map_center_Z);
if (imx <= 0 || imx >= Map_X ||
imy <= 0 || imy >= Map_Y ||
imz <= 0 || imz >= Map_Z) {
ray_points_world[j*3] = 0;
ray_points_world[1 + j*3] = 0;
ray_points_world[2 + j*3] = 0;
continue;
}
// 6. Y-высота в мировых мм
float y_world_mm_f = (rs1 + (float)Map_center_Y) * 100.0f;
int y_world_mm = __float2int_rd(y_world_mm_f);
if (y_world_mm < 0) y_world_mm = 0;
if (y_world_mm > Map_Y * 100 - 1) y_world_mm = Map_Y * 100 - 1;
ray_points_world[j*3] = (uint16_t)imx;
ray_points_world[1 + j*3] = (uint16_t)(y_world_mm + 1);
ray_points_world[2 + j*3] = (uint16_t)imz;
}
}
__global__
void voxelization_cuda(uint16_t *voxels,
const uint16_t *ray_points_world,
const unsigned int counter)
{
int i = blockDim.x * blockIdx.x + threadIdx.x;
if (i >= counter) return;
int stride = blockDim.x * gridDim.x;
for (int j = i; j < counter; j += stride) {
uint16_t imx = ray_points_world[j*3];
uint16_t y_mm1 = ray_points_world[1 + j*3];
uint16_t imz = ray_points_world[2 + j*3];
if (y_mm1 == 0) continue;
uint16_t iy = (uint16_t)((y_mm1 - 1) / 100);
uint32_t idx = get_value(imx, iy, imz);
voxels[idx] = y_mm1;
}
}
Размер усечённой пирамиды вычисляется один раз на основе данных из SDK RealSense и параметров камеры, прошитых в её EEPROM при заводской калибровке.
// ============================================================================
// Frustum binning parameters
// ============================================================================
#define FRUSTUM_MAX_LAYERS 256
static const float CAM_NEAR_MM = 500.0f;
static const float CAM_FAR_MM = 5000.0f;
static const float FRUSTUM_VOXEL_MM = 100.0f;
__constant__ int c_Nz;
__constant__ int c_Nx_per_layer[FRUSTUM_MAX_LAYERS];
__constant__ int c_Ny_per_layer[FRUSTUM_MAX_LAYERS];
__constant__ int c_layer_offset[FRUSTUM_MAX_LAYERS];
__constant__ float c_tan_half_h;
__constant__ float c_tan_half_v;
__constant__ float c_cam_near_mm;
__constant__ float c_cam_far_mm;
extern "C" __host__
int init_frustum_cuda(const rs2_intrinsics &intrin)
{
cudaError_t result;
float fov[2];
rs2_fov(&intrin, fov); // градусы
float fov_h_rad = fov[0] * (float)M_PI / 180.0f;
float fov_v_rad = fov[1] * (float)M_PI / 180.0f;
float tan_half_h = tanf(fov_h_rad / 2.0f);
float tan_half_v = tanf(fov_v_rad / 2.0f);
const float NEAR_MM = CAM_NEAR_MM;
const float FAR_MM = CAM_FAR_MM;
const float VOX_MM = FRUSTUM_VOXEL_MM;
int Nz = (int)((FAR_MM - NEAR_MM) / VOX_MM);
assert(Nz <= FRUSTUM_MAX_LAYERS && "frustum layers exceed FRUSTUM_MAX_LAYERS");
int Nx_arr[FRUSTUM_MAX_LAYERS] = {0};
int Ny_arr[FRUSTUM_MAX_LAYERS] = {0};
int off_arr[FRUSTUM_MAX_LAYERS] = {0};
int offset = 0;
for (int iz = 0; iz < Nz; ++iz) {
float z_center = NEAR_MM + iz * VOX_MM + VOX_MM * 0.5f;
float half_x = z_center * tan_half_h;
float half_y = z_center * tan_half_v;
int Nx = (int)floorf(2.0f * half_x / VOX_MM);
int Ny = (int)floorf(2.0f * half_y / VOX_MM);
if (Nx < 1) Nx = 1;
if (Ny < 1) Ny = 1;
Nx_arr[iz] = Nx;
Ny_arr[iz] = Ny;
off_arr[iz] = offset;
offset += Nx * Ny;
}
int total_voxels = offset;
result = cudaMemcpyToSymbol(c_Nz, &Nz, sizeof(int)); assert(result == cudaSuccess);
result = cudaMemcpyToSymbol(c_Nx_per_layer, Nx_arr, sizeof(int) * Nz); assert(result == cudaSuccess);
result = cudaMemcpyToSymbol(c_Ny_per_layer, Ny_arr, sizeof(int) * Nz); assert(result == cudaSuccess);
result = cudaMemcpyToSymbol(c_layer_offset, off_arr, sizeof(int) * Nz); assert(result == cudaSuccess);
result = cudaMemcpyToSymbol(c_tan_half_h, &tan_half_h, sizeof(float)); assert(result == cudaSuccess);
result = cudaMemcpyToSymbol(c_tan_half_v, &tan_half_v, sizeof(float)); assert(result == cudaSuccess);
result = cudaMemcpyToSymbol(c_cam_near_mm, &NEAR_MM, sizeof(float)); assert(result == cudaSuccess);
result = cudaMemcpyToSymbol(c_cam_far_mm, &FAR_MM, sizeof(float)); assert(result == cudaSuccess);
std::cout << "[CUDA] init_frustum_cuda: Nz=" << Nz
<< " total_voxels=" << total_voxels
<< " fov_h=" << fov[0] << " fov_v=" << fov[1] << std::endl;
return total_voxels;
}
Полные листинги, включая rotate_ray_points_to_world и финальную voxelization_cuda, лежат в репозитории.
Результаты: HD-карта и новая головная боль
После реализации можно перейти к тестам. Вот карта высот, построенная на основе дискретного воксельного представления:
А вот HD-карта высот с разрешением 1 мм. Меньше датчики всё равно не позволяют увидеть, так-что это и то с запасом:
Шума на карте действительно прибавилось. Если раньше на 2D-карте стоимости преобладали преимущественно ровные белые поверхности вокселей, то теперь карта постоянно мигает различными оттенками красного и синего.
Почему? Это минимальный уклон от 1 до 5 градусов по оценке уклонов на карте стоимости. Раньше в вокселях его не было видно, теперь он распределён равномерно по всей площади. Плюс шум глубины.
Если запустить штатный RealSense Viewer (что я и сделал в конце видео) и преобразовать глубину в облако точек, наложив текстуру от камеры, можно заметить, что асфальт идёт волнами и выглядит абсолютно кривым. Это шум глубины. На обычной 2D-цветной картинке с камеры его кривизны не видно (потому-что её нет, не на столько большой) — заметен только небольшой уклон.
Однако, решив проблему увеличения разрешения карты, сразу же получаю другую: скачки одометрии по высоте стали заметны ещё сильнее. В этой карте ось Y соответствует высоте, и скачки приводят к появлению на ровной поверхности непроходимых зон и артефактов с высокой стоимостью. Особенно хорошо это видно по краям frustum view: они теперь видны на карте как граница где часто обновляющиеся пиксели текущего скана соседствуют на фоне статичных пикселей карты.
Сам шум тоже не даёт ничего хорошего. С одной стороны, даже с ним видно более точную высоту препяствий т.к. шум обычно находится в примерно одном диапазоне случайного блуждания. С другой стороны, если оставить его как есть, планировщик будет совершать лишние манёвры, пытаясь объехать шум. Это неприемлемо.
Elevation Mapping cupy: готовое решение, но не для всех.
Решение обеих проблем — шума и скачков высоты — уже существует в готовом пакете Elevation Mapping cupy Он тоже работает на GPU, но написан не на CUDA, как можно догадаться а на CuPy — интерпретаторе, который переводит Python-код в CUDA-код для GPU.
В ключевых свойствах пакета указано:
компенсация дрейфа по высоте: устраняет ошибки оценки состояния, которые могут приводить к артефактам на карте;
очистка данных и удаление артефактов: raycasting и исключающие зоны позволяют убирать ложные артефакты и корректно интерпретировать нависающие препятствия;
фильтр проходимости на основе машинного обучения: оценивает проходимость с учётом локальной геометрии;
универсальные инструменты для перемещения: фильтры сглаживания и сегментация плоскостей;
Multi-Modal Elevation Map: интеграция геометрических, семантических и цветовых данных;
высокая производительность за счёт ускорения на GPU.
Некоторые из этих возможностей в моей карте уже есть: raycasting, удаление нависающих препятствий, GPU-ускорение. Но до остальных ещё далеко.
Можно было бы свернуть разработку и просто использовать Elevation Mapping cupy. Но есть проблемы.
Во-первых, Python-код пакета плохо интегрируется в мой C++/Qt-проект. ROS-разработчики сейчас, наверное, злорадствуют: у них ноды изолированы и могут запускать пакеты на разных языках. В случае с Qt придётся городить аналогичный механизм.
Во-вторых, пакет активно использует нейросети для сегментации изображения и расчёта карты проходимости. В отличие от моего пакета, где для расчёта уклонов использовался алгоритм МНК из Map-Conversion-3D-Voxel-Map-to-2D-Occupancy-Map, здесь применяется ML-фильтр проходимости.
В результате пакет не тянет на моей слабой видеокарте ровера — RTX 3050 Ti с 4 GB VRAM. Ему нужно минимум 6 GB VRAM, и рекомендуется RTX 4060. Это плохо, если мы хотим строить только лишь одну конкретную карту. Вот к чему приводит Python плюс нейросети.
А ведь ресурсы GPU неплохо было бы приберечь на будущее и для других задач, не этого конкретного но абстратного GPU будущего: для моделей распознавания, возможно, VLA или физического ИИ, который будет управлять роботом через планеры и низкоуровневые контроллеры. В конце концов, тот же MPPI у меня планировался на GPU. Но места на нём становится всё меньше. Если сейчас я стараюсь писать CUDA-код, чтобы хоть чем-то полезным занять GPU помимо визуализации, то в будущем его ресурсы вполне могут стать дефицитом.
Что дальше
Чтобы не бросать свой проект карты — он даёт полный контроль над разработкой и понимание всех процессов — и не втягиваться в гонку по апгрейду железа, я попробую взять только самое важное что-бы сделать карту более экономной по потреблению ресурсов GPU.
Планирую позаимствовать некоторые полезные решения из Elevation Mapping cupy:
одномерный фильтр Калмана для фильтрации шума;
компенсацию дрейфа и скачков по высоте;
заполнение пустот карты.
Поскольку я не знаю даже обычный Python, а тем более CuPy, полагаться в этом буду на LLM и нейросети: они помогут перевести код с CuPy на CUDA, после чего буду проверять ИИ код и дорабатывать им свою карту. В крайнем случае, если не получится, придётся просто купить больше NVIDIA, чтобы запускать Elevation Mapping cupy, где всё это уже сделано и работает на GPU.
Выводы
Дискретная воксельная карта с фиксированным разрешением — ограниченный инструмент. На уклонах она создаёт ложные перепады высот и загоняет планировщик в три жёстких состояния: ровно, неровно, непроходимо.
Переход к HD-карте высот с хранением точной высоты в ячейке даёт больше информации: появляются нормали, точнее оценивается проходимость, появляется возможность плавно учитывать уклоны. Но вместе с разрешением обнажаются шум сенсора и скачки одометрии по высоте.
Следующий шаг — фильтрация, компенсация дрейфа и заполнение пустот. Если получится сделать это на CUDA без нейросетей и Python-зависимостей, карта останется лёгкой и при этом станет заметно точнее.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.