Мне давно хотелось сколотить деревянную книжную полку, чтобы наконец пристроить книги, которые вечно валялись по всему дому. Но для этого нужно было возиться с рулеткой и делать замеры вручную.



А поскольку живем мы в эпоху воплощенного ИИ, мне пришла в голову мысль: а почему бы не создать систему, которая сделает всю рутину за меня?
Конечно, можно было просто взять обычную рулетку. Но согласитесь, это же совсем не так весело.
Для начала нужно отснять пространство, реконструировать его и получить плотное облако точек.
Результат радует глаз – неважно, используете ли вы облако точек или 3D-гауссианы (3DGS). Но как только вы попытаетесь применить эту модель на практике, внутренний голос непременно скажет:
Картинка красивая, но она не ответит ни на один вопрос: Где тут стены? Сколько стульев в комнате? Какова площадь пола?
Само по себе «сырое» облако точек или массив гауссиан – это лишь красивая цифровая декорация. Обидно, ведь вживую мы мгновенно выбираем взглядом все эти объекты.
Так что же способно превратить эту груду пикселей в данные, с которыми может работать компьютер?
Чистая геометрия без смысла – лишь каркас. По-настоящему оживляет пространство только семантика.
Нужно наполнить модель смыслом, к которому можно обращаться с запросами. Как только точка «осознает», что она – часть пола, безликое облако прекращает быть просто набором координат и становится комнатой.
И вот что принципиально изменилось за последние два года.
Нам больше не нужно обучать отдельную нейросеть под каждый новый объект. Мощные современные нейросети компьютерного зрения – сегментаторы, классификаторы с открытым словарем, энкодеры с самообучением – и так прекрасно понимают, что изображено на картинке. Сегодня главное мастерство – это не обучение моделей, а их оркестровка.
Весь фокус в том, чтобы перенести знания предобученной модели из плоского мира 2D-изображений в трехмерное облако точек, где они нам и нужны.
В этом руководстве мы пройдем весь путь: возьмем наспех снятое на телефон видео, превратим его в плотное облако точек, а затем послойно наполним смыслом. Мы сориентируем модель по силе тяжести, масштабируем в метры, разметим словами вместо бесконечного кликанья мышкой, научим систему отслеживать выделенный объект от кадра к кадру, устраним неизбежное мерцание меток и, наконец, нарежем облако в готовый чертеж.
И заметьте: мы не обучим ни одной нейросети.
И да, ирония судьбы: по такой модели я действительно спроектировал и собрал настоящую тумбу для кухни. Я не сделал ни одного замера вручную и она встала как влитая!
Если уж компьютер способен понять параметры комнаты настолько точно, то что мешает использовать этот подход в более серьезных задачах?
❯ Миссия
Представьте картину: вы лениво проходите по комнате с телефоном, снимая все подряд – просто чтобы запомнить, где что лежит. Без профессионального оборудования, никаких геодезических маркеров или выверенного перекрытия кадров. Специалист не назвал бы такое видео качественными данными.
Но ваша цель – превратить эту любительскую съемку в точную, размеченную 3D-сцену: где пол – это пол, стены – стены, а диван легко распознается со всех ракурсов, и все это можно измерить до миллиметра. Размечать миллионы точек вручную, кликая по ним всю неделю, – удовольствие сомнительное. И вы понимаете, что в этом нет нужды: ведь глубокое понимание геометрии и объектов уже заложено в готовых моделях.
Отличный обходной путь найден. Осталось проверить, выдержит ли этот техпроцесс проверку практикой. Как связать плоские изображения с трехмерным миром так, чтобы конструкция не расползлась по швам?
❯ Предлагаемый техпроцесс
Я разделил процесс на девять шагов. Это не просто список дел: каждый этап готовит почву для следующего, поскольку тот критически от него зависит.
Загрузка реконструированных данных и, самое важное, связей между пикселями и трехмерными точками.
Выравнивание облака точек по силе тяжести относительно плоскости пола.
Распознавание типа помещения и масштабирование сцены по высоте потолка.
Построение проекционной связи с учетом окклюзии (тест глубины).
Поиск объектов с помощью SAM и подбор названий через CLIP.
Отслеживание выделенного объекта на всех кадрах с помощью DINOv2.
Устранение мерцания меток путем голосования по множеству ракурсов.
Интерактивная корректировка человеком с автоматическим разнесением исправлений.
Нарезка облака точек для получения детального плана этажа.
Архитектура держится на одной фундаментальной идее, своего рода Розеттском камне. Этот древний артефакт стал ключом к разгадке иероглифов лишь потому, что один и тот же текст был высечен на нем на двух языках. Связь пикселей и точек облака – это как раз такой двуязычный ключ. Изображение говорит на одном языке, облако точек – на другом. Стоит жестко зафиксировать эту связь, и любая модель, умеющая работать с картинкой, мгновенно начинает понимать 3D-пространство.
❯ Данные, код и окружение
Сразу устраним все технические препятствия. Нам понадобятся три составляющие: среда Python, тестовый датасет и немного терпения к сумасбродствам Windows.
Для работы я использую стандартное окружение Conda. В режиме разработки этот конвейер обходится силами NumPy и SciPy, благодаря чему вы сможете оценить все промежуточные результаты и графики без подключения GPU.
conda create -n spatial_ai python=3.11 -y conda activate spatial_ai pip install numpy scipy matplotlib open3d # Понадобится только при интеграции реальных нейросетей (когда DEV = False): pip install torch torchvision transformers
Нашим подопытным кроликом станет реальный скан комнаты room.ply объемом около 2,2 миллиона точек. Чтобы не терять время при отладке, сожмем его до 40 000 точек.
В файле уже прописаны цвета (RGB) и класс для каждой точки – мы будем считать эти классы эталоном ground-truth, который системе предстоит восстановить. В реальном продакшене этот файл генерируется вашей системой реконструкции по видео и содержит точные параметры камер и кадров.
Структура проекта очень проста: файл облака точек, утилиты для визуализации и сам рабочий скрипт лежат в одной папке, чтобы импорты не раздували код. А вот так выглядят исходные данные – съемка с рук, без штативов и подготовки:

В Windows чаще всего подстерегают три досадные проблемы:
Во-первых, это кодировка консоли: скрипту приходится принудительно задавать UTF-8 для стандартного вывода
stdout, иначе стандартная системная кодовая страница Windows искажает служебные строки логов при выводе графиков.Во-вторых, некоторые библиотеки для загрузки облаков точек молча сбоят, если в путях к файлам встречаются символы не из ASCII. Чтобы избежать подобных тихих ошибок, старайтесь сохранять данные в папках с простыми латинскими названиями.
В-третьих, если вы переносите JSON-файлы параметров камер между разными компьютерами, они тоже обязаны быть в UTF-8, иначе парсер упадет на валидных числах с плавающей точкой из-за различия в системных разделителях.
На решение каждой из этих проблем я когда-то убил по целому вечеру.
Какая деталь связывает всю систему воедино? Наш Розеттский камень – связующий линк. С него и начнем.
❯ Шаг 1. Загрузка реконструированных данных и связей между пикселями и точками
Раньше я вечно торопился на этом этапе, за что потом расплачивался годами. Обычно разработчик просто загружает облако точек и сразу бросает его в обработку, напрочь забывая о самом ценном ресурсе.

Дело в том, что результат реконструкции – это не просто геометрия. Это триада: сами 3D-координаты, кадры с камеры и история происхождения каждой точки, связывающая ее с конкретным пикселем на исходном снимке. Первые два элемента очевидны всем, но именно третий многократно удешевляет все последующие вычисления.
Эта связь окупается мгновенно. Стоит прогнать кадр через нейросеть сегментации и получить 2D-маску, как перенос разметки в 3D превращается в обычный поиск по таблице. Никаких сложных обратных проекций и проверок на перекрытие.
def load_scene(): """Возвращает словарь сцены: xyz, rgb, метки, метаданные кадров, пиксель-точечный линк.""" if not DEV: sys.path.insert(0, NEURONES_3D) from core import scene_semantics, pixel_projection # noqa: F401 scene = scene_semantics.load_scene(DATA_FILE) # В продакшене файл связей (.npz) формируется самим движком реконструкции. return scene # Режим разработки (DEV): загружаем скан сцены с эталонной разметкой. c = dv.load_cloud(DATA_FILE, max_points=40000, want_labels=True) xyz = c["xyz"].astype(np.float32) rgb = c["rgb"] labels = c["labels"] if c["labels"] is not None else np.zeros(len(xyz), int) # Синтезируем ракурсы камер вокруг сцены и определяем связь пикселей с точками. img_h, img_w = 540, 960 K = np.array([[700, 0, img_w / 2], [0, 700, img_h / 2], [0, 0, 1]], float) n_frames = 6 frames, cams = [], [] center = xyz.mean(axis=0) radius = float(np.linalg.norm(xyz.max(0) - xyz.min(0))) * 0.6 for f in range(n_frames): ang = 2 * np.pi * f / n_frames pos = center + radius * np.array([np.cos(ang), np.sin(ang), 0.25]) fwd = center - pos fwd /= np.linalg.norm(fwd) right = np.cross(fwd, [0, 0, 1]); right /= np.linalg.norm(right) up = np.cross(right, fwd) R = np.stack([right, -up, fwd]) # Матрица перехода мир -> камера frames.append((img_h, img_w)) cams.append({"frame": f, "R": R, "position": pos, "intrinsic": K}) return {"xyz": xyz, "rgb": rgb, "labels": labels, "frames": frames, "cams": cams, "K": K, "img_shape": (img_h, img_w), "names": _class_names(labels), "n_points": len(xyz)}
Здесь важен один нюанс: соглашение об ориентации камер. Матрицу R я храню как преобразование поворота из мировой системы координат в систему камеры, а position – как оптический центр камеры в мировом пространстве. Такая схема идеально подходит к геометрии проецирования на шаге 4.
Виртуальное кольцо из шести камер – это просто заглушка для режима отладки, чтобы алгоритмам было за что зацепиться. В реальной работе эти параметры приходят из алгоритмов Structure-from-Motion (SfM), обработавших ключевые кадры видео, а связь записывается в виде компактного массива: для каждой точки указывается индекс кадра и координаты ее пикселя. Сохраните этот массив, и вам больше не придется мучаться с сопоставлением соответствий.
Пришел к этому через набитые шишки. В первых проектах я отбрасывал связь «пиксель – точка» и пытался восстановить ее на лету с помощью карт глубины. Всё работало, но дико медленно, да еще и с погрешностями. Стоило начать кешировать эту связь прямо на этапе реконструкции – как половина багов, которые я считал «проблемами сегментации», оказались обычным смещением камер, которое я теперь мог легко увидеть и исправить.

С геометрией разобрались. Но она перевернута. Почему правильное положение так важно на самом старте?
❯ Шаг 2. Выравнивание облака точек по силе тяжести относительно плоскости пола
Исходная реконструкция всегда создается в случайной системе координат – в нашем случае сцена оказалась наклонена и перевернута вверх тормашками. Самый простой и ленивый способ – покрутить ее руками в редакторе. Но гораздо надежнее вычислить вектор гравитации прямо из облака точек, ведь пол – это фундаментальный горизонтальный ориентир в любом интерьере.
Для этого мы находим доминирующую горизонтальную плоскость с помощью алгоритма RANSAC, а затем поворачиваем все облако точек так, чтобы нормаль к ней смотрела строго вверх. Расчет выполняется за доли секунды и избавляет от ручной настройки.
def fit_plane_ransac(pts, thresh=0.03, iters=300): """Возвращает (normal, d, inlier_mask) для уравнения n.x + d = 0 (найденная плоскость пола).""" best_inliers, best_n, best_d = None, None, None n_pts = len(pts) for _ in range(iters): i, j, k = RNG.choice(n_pts, size=3, replace=False) v1, v2 = pts[j] - pts[i], pts[k] - pts[i] nrm = np.cross(v1, v2) norm = np.linalg.norm(nrm) if norm < 1e-8: continue nrm = nrm / norm d = -nrm @ pts[i] dist = np.abs(pts @ nrm + d) inliers = dist < thresh if best_inliers is None or inliers.sum() > best_inliers.sum(): best_inliers, best_n, best_d = inliers, nrm, d return best_n, best_d, best_inliers
За работу функции отвечают два ключевых параметра. Порог расстояния (thresh=0.03, 3 сантиметра – до масштабирования) определяет допустимую «толщину» плоскости. Его нужно выбрать чуть выше уровня шума облака, чтобы даже неидеально ровный или шероховатый пол определился как единое целое.
Количество итераций (iters=300) гарантирует, что мы заведомо выберем три точки, лежащие именно на полу. Если изначально ограничить выборку самыми нижними точками, 300 прогонов хватит за глаза.
Если завысить порог погрешности, алгоритм начнет захватывать наклонный потолок и вектор гравитации завалится в сторону.
Я отбираю тройки точек для RANSAC только из нижних 35% по оси Z, а не по всему облаку. Этот простой трюк настолько повышает вероятность чистого попадания в пол, что количество итераций можно свести к минимуму, получая идеальный результат. В промышленной версии мы идем еще дальше – объединяем параллельные плоскости перед коррекцией кадра, но для 90% успеха достаточно и одного базового алгоритма.
Сам поворот реализуется через формулу Родрига, переводящую нормаль плоскости в вертикальную ось, и применяется ко всем точкам сцены одновременно.
def orient_to_floor(scene): """Поворачивает облако так, чтобы нормаль к полу совпадала с осью +Z.""" if not DEV: sys.path.insert(0, NEURONES_3D) from core import plane_refine return plane_refine.refine_planes(scene) xyz = scene["xyz"] z = xyz[:, 2] low = xyz[z < np.percentile(z, 35)] normal, _, inliers = fit_plane_ransac(low if len(low) > 500 else xyz) if normal is None: return scene if normal[2] < 0: # принудительно разворачиваем нормаль вверх normal = -normal target = np.array([0.0, 0.0, 1.0]) axis = np.cross(normal, target) s = np.linalg.norm(axis) if s < 1e-8: R = np.eye(3) else: axis /= s cos = float(np.clip(normal @ target, -1, 1)) ang = np.arccos(cos) kx = np.array([[0, -axis[2], axis[1]], [axis[2], 0, -axis[0]], [-axis[1], axis[0], 0]]) R = np.eye(3) + np.sin(ang) * kx + (1 - cos) * (kx @ kx) xyz_oriented = (R @ xyz.T).T.astype(np.float32) scene["xyz"] = xyz_oriented scene["floor_inliers"] = inliers return scene
В коде предусмотрена защита от вырожденного случая, когда нормаль уже смотрит строго вверх (чтобы избежать деления на ноль при векторном произведении, в этом случае возвращается единичная матрица). Также я принудительно разворачиваю нормаль вверх перед расчетами, так как у плоскости как таковой нет понятия верха и низа, иначе мы рискуем отправить комнату в подвал.

В нашем примере наклон пола составлял чуть больше 1°. Кажется, пустяк, но все последующие операции, завязанные на высоту (масштабирование, уровень среза, разделение пола и стен), критически зависят от того, насколько точно ось Z совпадает с реальным верхом.

Комнату мы выровняли, но она пока не имеет размеров. Математика фотограмметрии по изображениям безлика: алгоритм не знает, что он воссоздал – кукольный домик или готический собор. Как задать реальный масштаб в метрах?
❯ Шаг 3. Распознавание типа помещения и масштабирование сцены по высоте потолка
Модели, построенные по обычным фотографиям, лишены абсолютного масштаба. Чтобы привязать облако точек к реальной метрике, нужен хотя бы один известный размер. В интерьерной съемке этот размер нам дарит потолок.
Если построить гистограмму распределения точек по высоте, пол и потолок проявятся на ней в виде двух ярко выраженных плотных пиков, расстояние между которыми и есть высота комнаты. Задаем опорную высоту (например, 2,5 метра), и вся сцена мгновенно масштабируется под реальный мир.
def identify_and_scale(scene): """Выявляет плоскости пола и потолка, масштабирует сцену под заданную высоту потолка.""" if not DEV: sys.path.insert(0, NEURONES_3D) from core import scene_inventory, scene_transform scene = scene_inventory.identify(scene) return scene_transform.apply_scale(scene, CEILING_REFERENCE_M) z = scene["xyz"][:, 2] hist, edges = np.histogram(z, bins=80) centers = 0.5 * (edges[:-1] + edges[1:]) dense = centers[hist > 0.25 * hist.max()] floor_z, ceil_z = float(dense.min()), float(dense.max()) measured_height = ceil_z - floor_z scale = CEILING_REFERENCE_M / measured_height if measured_height > 1e-6 else 1.0 scene["xyz"] = (scene["xyz"] * scale).astype(np.float32) scene["scale"] = scale scene["room_type"] = "indoor room" return scene
Здесь важно поймать баланс с порогом плотности (hist > 0.25 * hist.max()). Пол и потолок – это самые густонаселенные горизонтальные плоскости в любом помещении. Четверть от максимального пика позволяет четко отсечь их от остального пространства, отфильтровав мебель и прочие мелкие объекты посередине.

Единственное уязвимое место этой схемы – жестко зашитая константа в 2,5 метра. Для типовой жилой комнаты это отличный ориентир, но для подвала или огромного ангара он окажется в корне неверным. Именно поэтому я обращаю на это внимание.
Замените жестко прописанную высоту адаптивным алгоритмом. Можно обучить крошечный классификатор помещений на основе облака точек или спросить у vision-модели «Что это за комната?» по первому кадру, затем подставить типичную высоту потолка для этого типа пространств. Офис – 2,7 метра, погреб – 2,2, шоурум – 3,5. В таком случае масштабирование станет динамическим и точным без ручного вмешательства.

Теперь перед нами выровненное и масштабированное в метрах пространство. Пора заняться связующим линком.
Как спроецировать данные с плоского двухмерного кадра на 3D-точки?
❯ Шаг 4. Построение проекционной связи с учетом окклюзии (тест глубины)
Именно здесь наша архитектура раскрывает свой потенциал во всей красе. Для каждого кадра мы проецируем все трехмерные точки на плоскость матрицы виртуальной камеры, затем строим поточечный буфер глубины (z-buffer). Это страхует от ошибок: точки, скрытые за другими объектами на переднем плане, не получат чужую разметку.
На выходе получаем точный список видимых из этой камеры точек с их экранными координатами. Дальнейший перенос масок элементарен: берем контур объекта на 2D-картинке и мгновенно находим все 3D-точки, которые за ним скрываются.
def project_points_to_frame(cam, img_shape, points, depth_tol=0.04): """Проецирует точки на матрицу камеры, возвращая усеченный список точек переднего плана.""" H, W = img_shape R = np.asarray(cam["R"], float) pos = np.asarray(cam["position"], float) K = np.asarray(cam["intrinsic"], float) Pc = (points - pos) @ R.T # Переход из мира в координаты камеры z = Pc[:, 2] front = z > 1e-6 zf = np.where(front, z, 1.0) u = np.round(K[0, 0] * Pc[:, 0] / zf + K[0, 2]).astype(np.int64) v = np.round(K[1, 1] * Pc[:, 1] / zf + K[1, 2]).astype(np.int64) inb = front & (u >= 0) & (u < W) & (v >= 0) & (v < H) idx = np.flatnonzero(inb) if idx.size == 0: return idx, np.empty((0, 2), np.int64) zbuf = np.full((H, W), np.inf) np.minimum.at(zbuf, (v[idx], u[idx]), z[idx]) keep = z[idx] <= zbuf[v[idx], u[idx]] * (1.0 + depth_tol) + 1e-6 idx = idx[keep] return idx, np.stack([u[idx], v[idx]], axis=1)
Допуск по глубине (depth_tol=0.04, т. е. 4%) – критически важная деталь. Без нее точка на далекой стене, просвечивающая сквозь дверной проем, получит класс двери, а разметка дивана расползется на висящие позади шторы.

4% допуска идеально удерживают передний план и отсекают задний фон. Этот параметр я подбираю под плотность сканирования: делаю его жестче для чистых сканов со штатива и мягче для любительских съемок с рук, где глубина слегка плавает. Магия z-буфера обсчитывается через векторную функцию np.minimum.at за один проход, поэтому процедура для всех шести камер занимает миллисекунды.
Обратите внимание – здесь нет циклов по пикселям. Проецирование, проверка границ и построение z-буфера реализованы как чистые операции над массивами. Для облака в 40 000 точек основное время уходит на заполнение z-буфера, но даже это делается единым системным вызовом. Вот почему линк работает молниеносно, и его можно перестраивать по первому требованию, а не избегать из-за медлительности.
# Шаг 4. Инициализация связей и проверка карты глубины на первом кадре. scene = build_pixel_link(scene)

Связь налажена. Теперь можно со спокойной душой вызывать тяжелую артиллерию – готовые нейросети.
Как же разметить трехмерное облако, не касаясь точек самому?
❯ Шаг 5. Поиск объектов с помощью SAM и подбор названий через CLIP
Всю работу выполняют две готовые модели, которые нам не пришлось обучать. Нейросеть SAM нарезает каждый кадр на логические фрагменты-пазлы, ничего не зная об их природе. Затем CLIP оценивает каждый кусочек по вашему текстовому списку классов и выбирает наиболее подходящее имя. Под «открытым словарем» (open vocab) как раз и понимается возможность передавать любые текстовые названия прямо на лету.
Для наглядности посмотрите на простейший вариант: если запустить геометрическую кластеризацию саму по себе, без привязки к семантике, на выходе получится винегрет из разрозненных, пусть и аккуратно разделенных сегментов, лишенных какого-либо смысла.
def propose_and_label_segments(scene): """Маркирует облако с помощью 2D-фрагментов SAM и текстовой классификации CLIP.""" if not DEV: sys.path.insert(0, NEURONES_3D) from core import cloud_open_vocab return cloud_open_vocab.label_cloud_openvocab( scene["xyz"], scene["rgb"], scene["link"], prompts=list(scene["names"].values())) gt = scene["labels"] noisy = gt.copy() flip = RNG.random(len(gt)) < 0.12 # вносим 12% искусственного шума в метки noisy[flip] = RNG.integers(0, gt.max() + 1, size=int(flip.sum())) scene["labels_noisy"] = noisy return noisy
В режиме отладки мы эмулируем сигналы нейросетей на основе эталонной разметки, намеренно подмешивая 12% случайного шума в классы точек. Именно с таким уровнем погрешности предстоит бороться алгоритму геометрического голосования на шаге 7. Число взято не с потолка – примерно такая погрешность возникает при работе реальных open-vocabulary-моделей на любительских видео, когда одна и та же точка под разными углами обзора распознается по-разному.

Проверить систему фильтрации на идеально чистых данных – значит заниматься самообманом. В реальной жизни всё гораздо грязнее.
Когда я впервые запустил связку SAM и CLIP на реальном скане, мне казалось, что самым сложным будет получить правильные названия объектов. Как бы не так. Настоящим кошмаром стало то, что у каждой камеры было свое мнение на один и тот же счет. При вращении сцены точки мерцали, хаотично меняя класс. Внутри кадра разметка была отличной, но согласовать ее между кадрами было той еще задачей. Этот опыт полностью изменил мой подход к архитектуре всей второй половины конвейера.
Как только к процессу подключается CLIP, хаос обретает строгую структуру: стены, пол, потолок, мебель.

Текстовые классы удобны, но порой у вас нет точного названия для объекта, зато есть живой пример перед глазами.

Что если бы можно было просто ткнуть пальцем в один предмет и приказать системе: «Найди мне такие же по всей сцене»?
❯ Шаг 6. Отслеживание выделенного объекта на всех кадрах с помощью DINOv2
Пожалуй, это самый изящный шаг во всем алгоритме, на котором стоит остановиться подробнее. Вы просто обводите нужный объект на одном из кадров обычным лассо, и нейросеть DINOv2 находит его на всех остальных ракурсах. Без текстовых имен, без дообучения сети и без огромной обучающей выборки – достаточно одного наброска.

Секрет кроется в патч-эмбеддингах. DINOv2 разбивает изображение на сетку мелких фрагментов и кодирует каждый из них в векторном пространстве.
В промышленном решении мы сперва отсекаем позиционную составляющую эмбеддингов – так сходство определяется именно по внешнему виду объекта, а не по его положению в кадре. Затем система усредняет векторы выделенных вами патчей в единый эталон-прототип и ищет его на целевых кадрах с помощью косинусного сходства.
def incontext_match(scene, target_class=TARGET_CLASS): """Ищет выделенный образец по эмбеддингам DINOv2 и переносит его маску в 3D.""" if not DEV: sys.path.insert(0, NEURONES_3D) from core import incontext_seg refs = scene["reference_outlines"] # Список шаблонов [(image, mask), ...] return incontext_seg.propagate(refs, scene["frames_dict"], image_size=768, tau=0.6) gt = scene["labels"] link = scene["link"] target_pts = set(np.flatnonzero(gt == target_class).tolist()) matched = np.zeros(len(gt), bool) for fid, fr in link.items(): vis = fr["idx"] is_target = np.array([int(i) in target_pts for i in vis]) keep = is_target & (RNG.random(len(vis)) < 0.90) # эмулируем 90% полноты детекции matched[vis[keep]] = True scene["matched_target"] = matched return matched
Параметры реальной рабочей функции полезно запомнить. Размер изображения приводится к 768 пикселям – это оптимально для патчевой сетки: меньшее разрешение упустит мелкие детали, а большее впустую перегрузит процессор смартфона. Порог объединения (tau=0.6) управляет агрессивностью группировки соседних патчей в единый объект. Значение 0,6 – золотая середина: диван определяется монолитно, а не рассыпается на груду отдельных подушек.
Во время отладки мы закладываем 90-процентную полноту распознавания на кадр, а затем объединяем результаты проекций со всех шести ракурсов. В итоге мы восстанавливаем форму объекта целиком, пусть даже на отдельных снимках он виден лишь частично. Это объединение данных под разными углами, ставшее возможным благодаря нашему линку, и есть тот самый незаметный герой проекта.
Разработчики DINO доказали, что обычный vision transformer, обученный вообще без разметки картинок, волшебным образом сам учится сегментировать объекты – карты внимания нейросети сами собой подсвечивают передний план. DINOv2 вывела эту идею на промышленный уровень. Такой контекстный поиск (показать один пример и найти аналогичные) очень напоминает то, как ребенок осваивает новое слово, когда ему просто показывают пальцем на предмет.

Всего один контур – нейросеть безошибочно подсвечивает нужный диван на всех кадрах видео, а наш Розеттский камень (линк) мгновенно переносит эту разметку на трехмерные точки.

Результат в 3D: один контур в 2D, спроецированный обратно на облако точек, позволил чисто разметить объект во всем пространстве.

Итак, мы разметили сцену двумя путями: через текстовые имена классов и через визуальные примеры. Одно плохо – разные камеры дают противоречивые данные.
❯ Шаг 7. Устранение мерцания меток путем голосования по множеству ракурсов
Вот подводный камень, о котором обычно умалчивают в статьях. Камера А видит точку как «стену», а камера Б – как «окно». В масштабе всей сцены это приводит к раздражающему мерцанию разметки, которое ломает любую дальнейшую автоматику, будь то расчет графа сцены или итоговый отчет.
Решением становится перекрестное голосование, где решающее слово остается за геометрией облака точек, а не за тем, какая камера выдала метку с большей вероятностью.
def spatial_consistency_vote(scene, labels, k=16, min_cluster=20): """Голосование k-ближайших соседей для устранения мерцания меток геометрии.""" if not DEV: sys.path.insert(0, NEURONES_3D) from core import semantic_refine cleaned, _ = semantic_refine.segment_consistency_refine( scene["xyz"], labels, list(scene["names"].values()), min_cluster=min_cluster, normal_angle_deg=12.0, k=k) return cleaned from scipy.spatial import cKDTree xyz = scene["xyz"] tree = cKDTree(xyz) _, nn = tree.query(xyz, k=k, workers=-1) # Индексы соседей матрицы (n, k) C = int(labels.max()) + 1 neigh_labels = labels[nn] # Матрица меток (n, k) counts = np.zeros((len(xyz), C), np.int32) for j in range(neigh_labels.shape[1]): counts[np.arange(len(xyz)), neigh_labels[:, j]] += 1 voted = counts.argmax(axis=1).astype(np.int32) return voted
Количество соседей (k=16) задает масштаб контекста, к которому прислушивается конкретная точка. Если сделать его слишком маленьким, тонкие элементы (например, ножки мебели) утонут в шуме окружения; если завысить – границы предметов размоются. Значение 16 отлично подходит для стандартной плотности интерьерных сканов.
В продакшене мы используем более тонкую логику, вот почему: у прямолинейного голосования есть слабое место – метка пола может легко «переползти» через перпендикулярный стык на нижнюю часть стены.
Важно разделять несущие конструкции и отдельные предметы – они требуют принципиально разных правил группировки. Пол или стены плоские, поэтому их границы стоит отслеживать с жестким фильтром по направлению нормалей (с порогом угла около 12 градусов), что не позволит разметке перешагнуть через острые стыки. Стул или диван имеют сложную форму, для них этот фильтр противопоказан, так как он разобьет сложный объект на куски; их мы группируем по чистому геометрическому расстоянию. Простое геометрическое голосование убирает львиную долю мерцания, но именно такое разделение на типы геометрии выводит систему на промышленный уровень.
# Снимаем мерцание разметки за счет геометрии облака. labels = spatial_consistency_vote(scene, labels)

Фильтрация уверенно убирает грязь и шум.

Но даже самая совершенная автоматика буксует на сложных стыках. В какой момент к процессу должен подключиться человек?
❯ Шаг 8. Интерактивная корректировка человеком с автоматическим разнесением исправлений
Скажу вещь, которая идет вразрез с нынешними ИИ-трендами: человек в этой цепочке – не слабое звено, а главный шлюз контроля качества. Наша цель – не избавиться от людей вовсе, а привлекать их только там, где экспертное решение превосходит любые алгоритмы. Причем само вмешательство сводится к минимуму.
Достаточно кликнуть на одну точку и указать: «Это пол». Система запустит алгоритм наращивания области по всей плоскости, аккуратно остановившись у физических преград. Один клик – и вся поверхность размечена безошибочно.
def human_refine(scene, labels, seed_class=0, n_seeds=1): """Наращивает область из выбранной оператором опорной точки-семени.""" if not DEV: sys.path.insert(0, NEURONES_3D) from core import semantic_refine out, _ = semantic_refine.seed_propagate( scene["xyz"], labels, list(scene["names"].values()), seeds=scene["user_seeds"]) return out from scipy.spatial import cKDTree xyz = scene["xyz"] gt = scene["labels"] seed_class = int(np.bincount(gt).argmax()) tree = cKDTree(xyz) _, nn = tree.query(xyz, k=12, workers=-1) # Сетка окружения (n, 12) cls_idx = np.flatnonzero(gt == seed_class) centroid = xyz[cls_idx].mean(axis=0) seed = int(cls_idx[np.argmin(np.linalg.norm(xyz[cls_idx] - centroid, axis=1))]) region = np.zeros(len(xyz), bool) region[seed] = True frontier = [seed] while frontier: cur = frontier.pop() for nb in nn[cur]: if not region[nb] and gt[nb] == seed_class: region[nb] = True frontier.append(int(nb)) out = labels.copy() out[region] = seed_class return out
Алгоритм использует граф k-ближайших соседей (k=12) для связности: заливка распространяется от точки к точке только по тем соседям, чьи геометрические характеристики согласуются. Это происходит мгновенно, так как граф строится всего один раз. Во время тестов один щелчок мыши позволил выровнять разметку для более чем восьми тысяч смежных точек, мимоходом исправив все ошибочные метки на их пути.
В этом и заключается синергия: человек задает вектор мысли (намерение), а геометрия берет на себя всю рутину по определению границ. Рабочий движок рассчитывает радиус наращивания на основе локальной плотности облака точек (обычно это три медианных расстояния до ближайшего соседа). Благодаря этому инструмент одинаково предсказуемо работает как на сверхплотных интерьерных сканах, так и на разреженных уличных съемках.
Конечно, выбор места для клика – это своего рода искусство. Тратьте свое внимание и клики на крупные, спорные поверхности, где автоматика может запнуться, а не на простые объекты, которые нейросеть и так щелкает как орешки.

В наших руках чистая, размеченная и правильно отмасштабированная трехмерная сцена. Самое время перейти к тому, чтобы превратить эту красоту в точные чертежи.
❯ Шаг 9. Нарезка облака точек для получения детального плана этажа
Большинству заказчиков не нужно само облако точек – им нужен плоский чертеж, с которого можно легко считать размеры помещений. Мы берем горизонтальный срез облака толщиной около пары сотен миллиметров на нужной высоте проектирования, сбрасываем координату Z – и получаем идеальное сечение комнаты.
Его габариты по осям X и Y дают точнейший контур помещения прямо из облака точек. Без ручной отрисовки в CAD-системах и блужданий по объекту с рулеткой.
def export_floorplan_slice(scene, thickness_m=0.30, out_path=None): """Формирует плоское горизонтальное сечение облака для чертежа.""" if not DEV: sys.path.insert(0, NEURONES_3D) from core import slice_export return slice_export.export_slice(scene, thickness=thickness_m) xyz = scene["xyz"] z = xyz[:, 2] cut = float(np.percentile(z, 5)) + 1.0 slab = (z >= cut - thickness_m / 2) & (z <= cut + thickness_m / 2) pts2d = xyz[slab][:, :2] if out_path is None: out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "floorplan_slice_dev.ply") ext_x = float(pts2d[:, 0].max() - pts2d[:, 0].min()) if len(pts2d) else 0.0 ext_y = float(pts2d[:, 1].max() - pts2d[:, 1].min()) if len(pts2d) else 0.0 with open(out_path, "w", encoding="utf-8") as f: f.write("ply\nformat ascii 1.0\n") f.write(f"element vertex {len(pts2d)}\n") f.write("property float x\nproperty float y\nproperty float z\nend_header\n") for x, y in pts2d: f.write(f"{x:.4f} {y:.4f} {cut:.4f}\n") return out_path
Здесь мы настраиваем два ключевых параметра: высоту среза (один метр над самым нижним уровнем пола) и толщину сечения (thickness_m=0.30). Высота в один метр – это классический архитектурный стандарт: срез проходит выше мебельного хаоса, но при этом четко рассекает стены, оконные и дверные проемы.

Важный момент: этот план полностью унаследует любые погрешности масштабирования, допущенные при съемке. Наспех сделанная панорама смартфоном отлично подойдет для эскизного наброска перед мелким ремонтом, но ее категорически нельзя использовать там, где требуются жесткие строительные допуски фактического состояния. Я привожу размеры на данных срезах в демонстрационных целях, но не подпишусь под абсолютной точностью, если съемка велась на обычный телефон. И вам делать этого тоже не советую.
Срез толщиной в 30 сантиметров – идеальный компромисс: он достаточно широк, чтобы зафиксировать даже разреженные стены на зашумленном облаке, и при этом достаточно тонок, чтобы на выходе получились четкие линии контуров, а не размытое пятно точек.
Для нашей тестовой комнаты алгоритм за один проход выдал точные габариты – примерно 7×7 метров.
В этом последнем шаге кроется готовая и очень востребованная услуга. Вы можете прийти на объект, за полминуты отснять его на телефон, дома прогнать видео через описанный конвейер и уже к вечеру отдать клиенту готовый размерный план и размеченную 3D-модель. И всё это – для объектов, владельцы которых не согласились бы оплачивать выезд профессионального геодезиста с лазерным сканером. Предложения по недвижимости, предстраховая оценка жилья, быстрый расчет смет для косметического ремонта… Съемка и обработка происходит быстро, клиент получает ценный и осязаемый продукт.

# Шаг 9. Нарезаем облако и вычисляем габариты. export_floorplan_slice(scene)

❯ Как превратить скрипт в готовый продукт с помощью ИИ


❯ Результаты, выводы и куда все это движется
Вернемся к изначальной задаче. Мы хотели, чтобы обычная любительская видеозапись могла отвечать на конкретные вопросы. И теперь наша система на них отвечает: вот здесь пол, комната имеет такие-то габариты, а диван находится тут. И секрет эффективности кроется не в мощи какой-то одной конкретной нейросети, а в том, как тонко мы настроили мосты между ними, вовремя корректируя процесс там, где это необходимо.
Этот системный навык оркестровки пригодится вам в любом проекте по работе с пространственными данными, это фундаментальный подход, а не ситуативное решение.
Но я буду предельно откровенен и расскажу, где эта система может дать сбой.
Связь между пикселями и точками напрямую зависит от точности совмещения (регистрации) кадров. Погрешность обратной проекции всего в пару пикселей размажет границы объектов по швам, и геометрическое голосование не спасет проект, если изначально криво построена траектория движения камер.
Алгоритмы распознавания с открытым словарем пасуют перед редкими и специфическими объектами, которых было мало в обучающих выборках CLIP и DINOv2, а также спотыкаются на прозрачных или глянцевых поверхностях, где отражения и блики искажают геометрию сцены. И даже наше голосование может сплоченно разнести неверную метку по всему облаку, если с определенных ракурсов все камеры стабильно ошиблись в одном и том же месте.
Наконец, результирующий чертеж неизбежно унаследует любые ошибки масштабирования, заложенные еще на этапе съемки. И точка.
Поэтому обязательно проводите валидацию на тестовой выборке перед тем, как запускать пайплайн в промышленную эксплуатацию, и всегда оставляйте за человеком последнее слово в критически важных узлах контроля. Эта система надежна как раз потому, что ее слабости нам хорошо известны и мы умеем их компенсировать.
Нейросети продолжат прогрессировать, они научатся сопоставлять и называть объекты со стопроцентной точностью. Дефицитным ресурсом останется понимание: какие связи в системе нужно защитить, какие ограничения заложить в код и куда именно кликнуть мышкой во время интерактивного исправления данных.
Я убежден, что в ближайшие два года мы перейдем от «разметки сканов» к «запросов к пространству». Как только сквозной линк пикселей и точек станет стандартом, а семантический разбор заработает без осечек, облако точек превратится в полноценную пространственную базу данных. К ней можно будет обращаться на естественном языке, и интеллектуальные ИИ-агенты смогут совершать действия через этот интерфейс. Тот конвейер, который мы собрали сегодня, – жизненно важный черновой фундамент.
❯ Полезные ресурсы
Научная статья по DINOv2, где описаны самообучающиеся эмбеддинги для контекстного сопоставления: https://arxiv.org/abs/2304.07193.
Научная статья по Segment Anything (SAM): https://arxiv.org/abs/2304.02643.
Научная статья по модели CLIP для классификации с открытым словарем: https://arxiv.org/abs/2103.00020.
Библиотека Open3D, на которую я постоянно опираюсь при чтении, записи и геометрической обработке облаков точек: https://www.open3d.org/.
Может быть интересно:

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩
