Парк, я тебя отрендерил

Из записей SpeedyBot собрали 3D-сцену парка. В первых вариантах крона перекрыла дорожку, а мангал раздвоился. Исправляли принадлежность наблюдений и подгонку геометрии по исходным кадрам и глубине.
Я Лев Ларин, инженер Unibot. Мы подключаем робота-доставщика Alfa Robotics SpeedyBot к своей платформе UNIBOT Fleet. Это восьмая часть серии: визуальная реконструкция по сохранённым данным.
Начало серии · ← Часть 7: К бабушке с пирожками — за край карты · Часть 9: Джойстик отпустили. Робот не в курсе →
Результат, который показываем
В выбранной исследовательской сцене 40 объектов: 27 растений, шесть предметов мебели, шесть архитектурных частей и грунт. GLB этой версии занимает 1 212 208 байт. Он содержит 24 373 треугольника и просматривается в локальном проверочном Fleet вместе с 2D-разметкой и роботом. Размеры части мебели ещё требуют проверки.



Инструменты реконструкции
Расчёт выполняется на отдельном компьютере по сохранённым данным робота:
Инструмент | Работа |
|---|---|
Детекции по текстовым категориям: bench, table, barbecue grill, bush, tree и другим | |
SAM 2.1 Hiera Tiny, PyTorch | Маски объектов по найденным рамкам |
OpenCV | Модели камер, устранение дисторсии, контуры и проекции |
NumPy, SciPy | Точки, пространственные связи, функции ошибок и |
Сборка геометрии и экспорт GLB | |
Three.js | Просмотр сцены в проверочном интерфейсе Fleet |
Детектор находит объект на картинке, SAM уточняет пиксельную маску. Глубина, калибровка и поза дают точки в метрах. Затем подбираем геометрию из библиотеки примитивов: мебель, кроны, архитектурные части. Частично видимая поверхность не задаёт полный габарит предмета.
В mask_object_observations.py один embedding изображения используется для всех рамок ракурса. Сокращённые вызовы:
from sam2.build_sam import build_sam2
from sam2.sam2_image_predictor import SAM2ImagePredictor
model = build_sam2(
'configs/sam2.1/sam2.1_hiera_t.yaml', checkpoint, device='cpu')
predictor = SAM2ImagePredictor(model)
predictor.set_image(np.array(rgb, copy=True)) # один раз на view
masks, scores, low_res = predictor.predict(
box=np.asarray(bbox, dtype=np.float32), multimask_output=True)
chosen = int(np.argmax(scores))
mask = masks[chosen].astype(bool) & valid_mask
Сохраняем альтернативные маски и оценки. Predicted IoU — оценка модели, не независимая проверка разметки. Кеш связан с RGB, valid mask, рамкой, checkpoint и версией обработки, поэтому геометрические опыты не требуют повторного запуска всех моделей.
Координаты, глубина и время
RGB-D, лидарные облака и позы приводим к native map в метрах, Z вверх. Используем sensor timestamps и траекторию конкретного проезда /app/get_pose. Время доставки пакета во Fleet и старый slam_traj.txt от карты эту траекторию не заменяют.
В новом сборе depth имеет размер 320×200 и encoding 32FC1, но значения устройства выражены в миллиметрах. Для метров делим на 1000. В подготовленном manifest глубина уже нормализована, повторное деление недопустимо.
depth и K подготовленного RGB-D вида согласованы. Ядро pipeline.measurements._depth_grid:
y, x = np.indices(depth.shape)
local = np.stack((
(x - K[0, 2]) * depth / K[0, 0],
(y - K[1, 2]) * depth / K[1, 1],
depth,
), axis=-1)
world = local @ map_from_camera[:3, :3].T + map_from_camera[:3, 3]
fx/fy — фокусные расстояния в пикселях, cx/cy — главный пиксель, map_from_camera — поза камеры в native map. Дисторсию широкоугольных RGB исправляем через cv2.fisheye.undistortPoints либо cv2.undistortPoints по модели камеры. Поворот изображения для просмотра калибровку не заменяет.
В этом сборе /cloud_registered имел frame_id=base_link. Применяем позу один раз. PointCloud2 читаем по fields/offsets/point_step: кроме XYZ буфер содержит другие поля и выравнивание.
На краю маски глубина может принадлежать фону за предметом. Поэтому принадлежность точек проверяется отдельно от калибровки. Четыре обзорных видеозаписи также не дают автоматически четыре метрически откалиброванных вида: в текущей проверке соответствия RGB-D нашлись для FRONT, для LEFT/BACK/RIGHT автоматическому этапу их не хватило.

Это кадры нового сбора 19 октября; накопленная сцена относится к другому этапу. Передача обзорного видео описана в третьей части, устройство робота — в отдельном обзоре.
Исправление ширины кроны
Маска объединяла ближние ветки и дальнюю растительность. Начальная ширина по разбросу этих XYZ давала низкую крону диаметром около 3,36 м поперёк дорожки.
В research/vegetation.py крона — жёсткий вертикальный примитив с подбираемыми XY, диаметром и высотой. Размеры параметризованы логарифмами для сохранения положительных значений.
Начальную ширину стали получать по полноценному калиброванному силуэту на глубине предмета. Контур исправляем от дисторсии, строим лучи в native map и пересекаем с вертикальной плоскостью на оценённой дальности. Поперечный размер берём между квантилями 1% и 99%, оценки видов объединяем медианой. Маска, обрезанная краем кадра, не считается полным силуэтом.
На тех же точках и масках получилась модель шириной около 0,43 м. Это сравнение моделей, не независимый замер растения.

Простое удаление разорванных облаков отвергли: оно удаляло и настоящие кусты. Промежуточную версию, считавшую все наблюдения семантически подтверждёнными, тоже отвергли после появления лишних растений на полном наборе. В выбранной сцене сохранены все 27 растений предыдущего контрольного варианта, ложных широких дисков на просмотренных кадрах нет.
Функция ошибки
Проецируем модель в исходные виды и сравниваем контур с маской через signed distance transform. Фрагмент residual:
signed = distance_transform_edt(~mask) - distance_transform_edt(mask)
delta = map_coordinates(signed, boundary[:, ::-1].T,
order=1, mode='nearest')
residuals = 3 * np.tanh(delta / 9.0) / image_scale
tanh ограничивает влияние большой ошибки одной маски, захватившей соседний объект. Обратная проверка сравнивает контур наблюдения с проекцией модели.
Лидарные и RGB-D-точки внутри листвы не обязаны лежать на внешней оболочке. Метрическая часть штрафует их выход за объём, не натягивая внутренние отражения на поверхность. Вызов подгонки:
fitted = least_squares(
residual, initial, bounds=(lower, upper),
loss='soft_l1', diff_step=1e-4,
max_nfev=max_evaluations,
)
soft_l1 уменьшает влияние выбросов, но не исправляет неверную принадлежность точек. Отбор наблюдений и fit проверяются отдельно.
Принадлежность и повторные наблюдения
Часть мангала на тележке была выделена как второй предмет. Этап размещения раздвигал и деформировал две модели одной конструкции. После объединения с родительским BBQ двойник исчез, исходная модель вернулась к позе и размеру до ошибочного размещения. Её внешний контур пока слишком широк, ориентация относительно стены не подтверждена. Семантическое удаление фона не приняли: оно называло реальные металлические части и колёса травой и деревом.
У столика нашли ошибку сопоставления: повторная маска A прикреплялась к B, поскольку A считался занятым предыдущей маской. Исправили принадлежность и зависимость веса поверхности от порядка и числа точек. В ограниченном повторе ширина осталась около 2,34 м; новая геометрия мебели не принята и выбранную сцену не заменила.
Маски содержат view_id и связь с измерениями. Повторы одного вида в подгонке крон исключаются:
view_id = row['view'].get('id')
if view_id is not None and view_id in seen_views:
continue
if view_id is not None:
seen_views.add(view_id)
Три маски одного RGB-D кадра используют те же глубину и позу, поэтому не считаются независимыми метрическими наблюдениями. Аналогично контролируем вес повторных точек и отделяем собственную поверхность мебели от зазоров и фона.
Экспорт и просмотр
В scene.json сохраняются frame="map", units="m", up_axis="z". Вершины и грани примитивов передаются в trimesh.Trimesh(...,process=False), затем в trimesh.Scene и GLB. Материалы и условные невидимые детали помогают читать сцену, но не считаются измеренными.
Three.js viewer согласован с системой координат 2D-карты. Проверяем совпадение ориентиров, робота и разметки при переключении вида. Движением и столкновениями занимается штатная навигация. Большая территория хранится фрагментами, как описано в части про атлас.
Возобновлённые стадии выбранного прогона заняли около 20 мин 46 с при лимите двух CPU и 4 ГиБ. Геометрия к этому моменту уже была готова; это не время полного холодного запуска. Малый GLB не включает исходные кадры, облака, маски и промежуточные результаты.
Итог — узнаваемая исследовательская сцена в локальном Fleet: исправлены плоские кроны и двойник BBQ. Открытая задача — обоснованные размеры и ориентация мебели. Полный рабочий цикл реконструкции в установленной Fleet этими опытами пока не подтверждён.
Все 10 историй SpeedyBot
Робот доставщик дороже автомобиля, мокрая трава и открытый JDWP
Парк, я тебя отрендерил — вы здесь.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.