Синтез фотореалистичных сцен, их точных карт глубины и сегментационных масок
Синтез фотореалистичных сцен, их точных карт глубины и сегментационных масок

Генерация, понимание и редактирование реалистичных изображений — всё ещё сложнейшая задача для ИИ. Потому качественные данные сегодня на вес золота, а компании готовы тратить миллионы на труд разметчиков и API мастодонтов вроде Gemini Pro Image. Такой подход не только предельно дорог и ресурсозатратен — но и полон ошибок, которых не лишены даже «генеративные ИИ-гиганты». 

Я хочу рассказать вам о другом, менее популярном сегодня методе сбора визуальных данных — автоматической сборке 3D-сцен и рендере их изображений. Конечно, и этот подход не лишен своих недостатков — но он быстр, дёшев и не так затратен, при этом он покрывает очень тяжёлые для современных моделей ниши. Такой метод позволяет детерминировано понимать и контролировать содержимое генерируемых данных с точностью до миллиметра. В этой статье мы с нуля построим полностью автоматический пайплайн формирования и генерации изображений и метаданных к ним в Blender — для задач генерации, понимания и редактирования изображений. А запускаться и работать он может на чём угодно — от GPU-серверов, до обычного домашнего ПК.

Полный код и примеры выложил в GitHub: https://github.com/georfed/BLIMP, можно сразу зайти туда и глянуть.

Что за пайплайн будем строить, и зачем?

Всем привет! Мы — команда The Layer в Сбере, занимаемся моделью инструктивного редактирования изображений, успешно работающей в проде в составе GigaChat (возможно, вы читали нашу статью на Хабре). В рамках исследований и доработки модели, мы проводим много экспериментов и, очевидно, активно собираем и обрабатываем данные самыми разными способами. В ходе этих процессов мы сделали пару неутешительных выводов, очень важных в контексте этой статьи:

  1. Проблема описания изображений. Только предельно дорогие и тяжёлые VLM могут описывать изображения с приемлемо стабильной корректностью. В основном же, при разметке и описании изображений через VLM стоит ожидать, что приличная часть данных будет описана ошибочно — и эту часть никак нельзя будет отфильтровать (если, конечно, не отсматривать лично миллион картинок вручную).

  2. Проблема редактирования изображений. Редактирование изображений тоже неконсистентно — модель может просто взять и совершить его некорректно. При этом даже SOTA-редакторам изображений практически не даётся ряд операций, подразумевающих продвинутое пространственное понимание изображения: перемещение и поворот объектов, изменение их размеров, смена ракурса камеры.

  3. Наложение этих проблем. Как отфильтровать ошибки редактирования на большом корпусе данных, без найма штата разметчиков и многомесячного ожидания? Только при помощи VLM.
    Но тогда проблема №1 накладывается на проблему №2, сводя эффективность к минимуму.

Так и получается: большие затраты — маленький выхлоп. Самое обидное, что эти проблемы проявляются ярче всего как раз на самых важных для конечного пользователя аспектах: фотореалистичность, точность исполнения инструкции, понимание содержимого изображения, сложные пространственные операции, сохранение деталей и черт исходного изображения…

На фоне этих проблем интересным источником данных кажется 3D-синтетика, и вот почему:

  • сбор данных вообще без участия ИИ, что также напрямую обходит приведённые выше проблемы;

  • современный 3D-рендеринг может достигать высочайшей фотореалистичности, при этом не имея присущих генеративному ИИ микро-артефактов;

  • генерация и обработка синтетики в разы дешевле и быстрее иных способов дата-майнинга;

  • полный контроль: на любом этапе, за любым аспектом, вплоть до крошечных деталей;

  • детерминированность: вероятностная природа ИИ больше не доставит нам хлопот;

  • полезные метаданные: мы знаем всё о расположении, габаритах, структуре каждого объекта на изображении — что представляет собой гарантированно корректное описание, недостижимое никакой VLM;

  • трёхмерность: 3D-движок позволяет получить очень полезные дополнения к каждой картинке: например, точная карта глубины и сегментационная карта — опять же, без помощи ИИ.

В этой статье мы с нуля напишем минимальную реализацию этого подхода, имплементировав все описанные выше преимущества. По ходу статьи будут пробы и ошибки, объяснения кода, иллюстрации идей и показ результатов.

В этой статье будет не крупный серьезный продукт, а простой рабочий MVP, который я сделал в рамках исследований по сбору синтетических данных. Мне он показался интересным и полезным, потому делюсь текущими наработками. Конечно, есть много недоработок, и большой потенциал к развитию. Скорее всего, скрипт будет совершенствоваться со временем, так что если интересно — поставьте звёздочку на репу)

Замечание: Хоть мы и будем формировать пайплайн сбора данных в контексте задачи редактирования изображений, эти же данные абсолютно применимы (и очень полезны) для любых других генеративных и визуальных задач. Так что метод потенциально полезен всем, кто работает над каким-либо визуальным ИИ.

Для удобного прочтения статьи все участки кода скрыты под спойлеры, а готовый полный скрипт, с примерами и пояснениями, лежит в GitHub: https://github.com/georfed/BLIMP. Приятного прочтения!

Постановка задачи, пререквизиты

Давайте сформируем чёткое ТЗ для нашего пайплайна.

Вход:
- набор объектов, которые мы будем расставлять;
- набор сцен, в которых мы будем расставлять эти объекты.

Выход:
рендеры (изображения) произвольных сцен с произвольно расставленными на них объектами.

Требования:
- пайплайн универсален, работает для любых сцен и объектов;
- объекты расставлены корректно: не пересекаются, не висят в воздухе, видны на рендерах;
- рендеры фотореалистичны: без неестественных особенностей и артефактов, похожи на повседневную обывательскую фотосъемку;
- для каждого рендера сохранены полезные метаданные о расположении и особенностях объектов на изображениях;
- для каждого рендера выполнены корректные карта глубины и сегментация добавленных объектов;
- на один рендер «по умолчанию» приходится несколько рендеров-редактирований, на которых что-то изменено;
- всех данных достаточно для формирования корректных описаний изображений и текстовых инструкций посредством регулярных выражений и/или простейших LLM.

Теперь добавим конкретики:

3D-среда

В качестве ПО и среды будем использовать Blender — это мощный, популярный и универсальный инструмент с открытым исходным кодом, отвечающий нашим требованиям. 

Возьмём Blender 4.5 (хотя, наверное, пайплайн заработает на любой 4.x версии). Конечно, недавно вышло масштабное обновление Blender 5.x — однако несколько опасно считать эту версию стабильной, особенно с точки зрения написания скриптов. В том числе, в 5.x было значительно изменено программирование нод, что не очень нас устраивает на определённых участках пайплайна (будет подробнее описано по ходу дела). 

Где взять объекты?

Подойдут любые имеющиеся у вас объекты (ассеты) любых форматов (.blend, .obj, .fbx, …)

Качественные объекты можно:

  • купить (или найти бесплатно) на площадках вроде poliigon.com, blenderkit.com, open3dlab.com, superhivemarket.com (ранее был известен как BlenderMarket);

  • приобрести целыми библиотеками фотограмметрически отсканированных объектов, например A23D.co, quixel.com;

  • найти в бесплатных исследовательских open-source библиотеках, вроде того-же Objaverse (есть даже официальный notebook с python-скриптами подгрузки любых интересных вам объектов).

Я же в качестве примера возьму несколько имеющихся у меня реалистичных .fbx-ассетов из продуктов cgaxis.com.

Где взять сцены?

В целом, ссылки и рекомендации те же. Я буду тестировать на нескольких произвольных сценах с подобных платформ.

Что сцены, что объекты рекомендую не парсить по случайным источникам, а брать полноценными профессиональными наборами и библиотеками — это гарантирует не только консистентность и качество, но и корректность объектов (например, реалистичное масштабирование и пропорции по умолчанию). 

Начинаем работу

Для начала я положил несколько тестовых .fbx объектов в одну папку (назовём её assets), открыл в Blender 4.5 произвольную .blend-сцену и перешёл на оверлей Scripting.

Не буду здесь приводить основы Blender (инте��фейс, управление, …) — это излишне, и займёт много места. ЦА этой статьи либо и так с этим знакома, либо без труда найдёт любой из бесчисленных обучающих материалов на эту тему. Отмечу лишь пару полезных нам деталей:

  1. Рекомендую запускать Blender из командной строки (инструкция). Так по ходу выполнения скриптов в терминале будут видны любые print-выводы, сообщения и ошибки, что значительно облегчит вам процесс отладки.

  2. Любое выполненное вами в GUI действие отобразится в виде команды нижнем левом окошке оверлея Scripting — что очень полезно, когда хотите узнать как программно изменить то или иное свойство.

Всё готово — приступаем к разработке!

Начальная расстановка

Итак, что мы имеем? Практически всегда произвольная .blend сцена, скачанная с какой-либо платформы — это гармонично расположенный набор объектов, освещение и хотя бы одна предрасположенная камера, уже настроенная на красивый рендер имеющейся композиции (опустим остальные особенности структуры сцены, нам важны только эти).

Зачем же тогда добавлять какие-то объекты? — спросите вы. Если сцена уже готова, то можно собирать данные, просто манипулируя имеющимися на ней ассетами!

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

типичная коллекция ассетов
типичная коллекция ассетов

Зачастую у объектов нет корректных названий (например, стол может называться asset_1_copy), и даже корректной иерархии (например, диван может состоять из нескольких раздельных не очевидно названных ассетов, никак не объединенных в общую коллекцию или под единый родительский объект).

попробуйте повернуть камеру в этой сцене…
попробуйте повернуть камеру в этой сцене…

При этом совершенно не очевидна степень проработки произвольной сцены — и даже немного повернув камеру можно легко захватить в финальный рендер часть не проработанного скайбокса или какой-нибудь обрывок стены без текстуры.

При этом мы не можем просто отказаться от таких «хаотичных» готовых сцен, вместо этого, например, с нуля конструируя свои — это чрезмерно сложная задача. Кроме того, несмотря на все минусы, такие готовые сцены с площадок почти гарантированно качественно проработаны с точки зрения визуала, эстетики и реалистичности.

Поэтому нам и нужны дополнительные объекты. Мы будем принимать такие готовые сцены как фундамент нашей работы, константу с фиксированной расстановкой объектов и фиксированным направлением камер — это гарантирует осмысленность и реалистичность как сцены, так и любого рендера через преднастроенные камеры.

Выбор и расположение объектов

Таким образом, есть фиксированная сцена и набор камер к ней. Задача: написать код произвольного выбора объектов из имеющегося набора, и корректного их расположения на сцене.

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

Гравитация

Первая идея была самая простая — что если загружать объекты в произвольные места, и затем ненадолго включать для них гравитацию, позволяя объектам физически корректно «упасть» на сцену? Объекты, переставшие падать через пару секунд, точно попали на сцену, и их можно считать корректными.

Идея кажется очень привлекательной, ведь встроенный движок физики позаботится об устойчивости и отсутствии коллизий. Однако на практике всё пошло не так гладко:

  1. физика сцены и добавляемых объектов просчитывается ну очень уж долго;

  2. физический движок Blender хорош, но часто не вытягивает хаотичную геометрию произвольных ассетов

Вкладывается много времени и ресурсов в просчёт физики, но вложения редко себя оправдывают: объекты сложно поставить в «интересные» места, многие падают мимо сцены, какие-то стоят в не обозреваемых камерами местах, — а некоторые вообще висят в воздухе, возможно «подпрыгнув» при коллизиях с другими объектами.

Итого, метод тяжёлый и ненадёжный.

Псевдо-гравитация

А что если так же произвольно добавлять объекты, но вместо гравитации физического движка сделать свою, примитивную? Можно просто двигать ассеты вниз по оси Z до тех пор, пока их нижние границы не коснутся какой-либо поверхности («встанут» на неё). Так мы сохраняем многие преимущества первого подхода, но код в разы проще и быстрее — можно просто «закидывать» сцену ассетами, оставляя только удачные.

Однако и здесь возникают проблемы:

  1. объекты опять почти нереально поставить в «интересные» места — например, под стол;

  2. на любой сцене есть много мест, которые программно считаются корректными для пос��ановки, а по факту — нет: за стенами, внутри текстур, за пределами видимости камер.

Но стоит отдать должное: простой и быстрый статистический подход в целом очень хорош. Если совершить много попыток расстановок, фильтруя любые неудачные, то шанс получить интересные расстановки очень высок. Осталось решить имеющиеся проблемы.

Лучи из камеры

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

Такой подход показал себя лучше всего, почти гарантируя расстановку объектов строго в пределах поля зрения камеры — а значит и в пределах корректно оформленной сцены.

Я остановился на этом подходе, привожу код с пояснениями:

выбираем объект
def import_random_asset(folder, generated_assets, excluded_names=None):
    if excluded_names is None: excluded_names = []
    p = Path(folder)
    # Ищем все FBX в папке рекурсивно
    files = [f for f in p.rglob('*.fbx') if f.stem not in excluded_names]
    if not files: return None

    # Пытаемся выбрать файл, который еще не импортировали в эту сцену
    for _ in range(100):
        filepath = str(random.choice(files).resolve())
        filename = Path(filepath).stem
        if len(generated_assets) == 0 or not any([obj.name == filename for obj in generated_assets]):
            break
            
    bpy.ops.import_scene.fbx(filepath=filepath)
    imported_objs = bpy.context.selected_objects
    if not imported_objs: return None
    
    # FBX часто состоят из кучи мелких деталей. Объединяем их в один меш.
    bpy.context.view_layer.objects.active = imported_objs[0]
    if len(imported_objs) > 1: bpy.ops.object.join()
    
    asset = bpy.context.active_object
    asset.name = filename
    set_origin_to_bottom(asset) # Сразу чиним Origin
    return asset
чиним его Origin для корректной постановки
def set_origin_to_bottom(obj):
    """
    Переносит Origin (опорную точку) объекта в самый низ его геометрии.
    Это критично, чтобы объекты не спавнились 'по пояс' в полу.
    """
    mw = obj.matrix_world
    depsgraph = bpy.context.evaluated_depsgraph_get()
    eval_obj = obj.evaluated_get(depsgraph)
    mesh = eval_obj.to_mesh()
    
    # Ищем минимум по Z среди всех вершин в мировых координатах
    world_verts_z = [(mw @ v.co).z for v in mesh.vertices]
    
    if not world_verts_z:
        eval_obj.to_mesh_clear()
        return

    min_z = min(world_verts_z)
    
    # Вычисляем центр (чтобы Origin был внизу по центру, а не в углу)
    world_verts_x = [(mw @ v.co).x for v in mesh.vertices]
    world_verts_y = [(mw @ v.co).y for v in mesh.vertices]
    center_x = (max(world_verts_x) + min(world_verts_x)) / 2
    center_y = (max(world_verts_y) + min(world_verts_y)) / 2

    # Blender не умеет просто так сетить Origin по координатам.
    # Трюк: ставим 3D-курсор в нужную точку -> говорим Origin to Cursor.
    prev_cursor_loc = bpy.context.scene.cursor.location.copy()
    bpy.context.scene.cursor.location = (center_x, center_y, min_z)
    
    bpy.ops.object.select_all(action='DESELECT')
    obj.select_set(True)
    bpy.context.view_layer.objects.active = obj
    bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
    
    # Возвращаем курсор на место и чистим память
    bpy.context.scene.cursor.location = prev_cursor_loc
    eval_obj.to_mesh_clear()
ищем потенциально хорошие места нашим методом
def set_origin_to_bottom(obj):
    """
    Переносит Origin (опорную точку) объекта в самый низ его геометрии.
    Это критично, чтобы объекты не спавнились 'по пояс' в полу.
    """
    mw = obj.matrix_world
    depsgraph = bpy.context.evaluated_depsgraph_get()
    eval_obj = obj.evaluated_get(depsgraph)
    mesh = eval_obj.to_mesh()
    
    # Ищем минимум по Z среди всех вершин в мировых координатах
    world_verts_z = [(mw @ v.co).z for v in mesh.vertices]
    
    if not world_verts_z:
        eval_obj.to_mesh_clear()
        return

    min_z = min(world_verts_z)
    
    # Вычисляем центр (чтобы Origin был внизу по центру, а не в углу)
    world_verts_x = [(mw @ v.co).x for v in mesh.vertices]
    world_verts_y = [(mw @ v.co).y for v in mesh.vertices]
    center_x = (max(world_verts_x) + min(world_verts_x)) / 2
    center_y = (max(world_verts_y) + min(world_verts_y)) / 2

    # Blender не умеет просто так ставить Origin по координатам
    # поэтому ставим 3D-курсор в нужную точку -> говорим Origin to Cursor.
    prev_cursor_loc = bpy.context.scene.cursor.location.copy()
    bpy.context.scene.cursor.location = (center_x, center_y, min_z)
    
    bpy.ops.object.select_all(action='DESELECT')
    obj.select_set(True)
    bpy.context.view_layer.objects.active = obj
    bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
    
    # Возвращаем курсор на место и чистим память
    bpy.context.scene.cursor.location = prev_cursor_loc
    eval_obj.to_mesh_clear()
набросок цикла расстановки
def generate_initial_assets(scene, cam):
    generated_assets = []
    depsgraph = bpy.context.evaluated_depsgraph_get()

    for attempt in range(MAX_ATTEMPTS):
        if len(generated_assets) >= MAX_ASSETS: break

        asset = import_random_asset(ASSET_FOLDER, generated_assets)
        if not asset: continue
        success = False
        
        # Цикл попыток размещения одного объекта
        for r in range(MAX_RETRIES):
            surface_obj, loc, normal = find_random_surface_location(scene, cam, depsgraph)
            
            # Проверяем наклон поверхности (чтобы не ставить на стены)
            if surface_obj and normal.z >= math.cos(math.radians(MAX_SURFACE_SLOPE)):
                asset.location = loc
                # Случайный поворот вокруг оси Z
                asset.rotation_euler[2] = random.uniform(0, 2 * math.pi)
                

        
        if success:
            generated_assets.append(asset)
        else:
            # Если место не нашлось — удаляем
            bpy.data.objects.remove(asset, do_unlink=True)
            
    return generated_assets

Коррекция объектов

Уход от реальной физики дал и свои недостатки. Выбранный нами вариант расстановки объектов никак не решает три основные проблемы:

  1. объект может своим мешем пересекаться со сценой или другими объектами;

  2. объект может быть нереалистично неустойчив (слишком малая часть нижней плоскости реально на чём-то стоит);

  3. объект может быть слишком сильно перекрыт другими объектами.

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

Выполнить N раз:
	Испустили луч
	Если луч упал на достаточно горизонтальное место:
		Поставили объект
		Проверили непересечение
		Проверили устойчивость
		Проверили видимость

Поэтому каждая такая проверка должна быть предельно быстрой. Расс��отрим их реализации по порядку:

Проверка пересечений

Для эффективной проверки коллизий одного меша со сложной сценой в 3D-среде используется такая структура как BVH-tree.

Это дерево строится не мгновенно, но для нас это не проблема: поскольку каждую новую попытку постановки объекта мы сравниваем с одной и той же сценой, нам достаточно построить BVH-дерево сцены лишь один раз, и каждую новую попытку сравнивать с ним. А если мы уже добавили какой-то объект и хотим добавлять ещё, то можно довольно быстро пересчитать уже имеющееся дерево с учётом добавленных объектов, чтобы будущие попытки учитывали и их.

строим BVH-дерево
def build_scene_bvh(scene, depsgraph, exclude_objs=None):
    """
    Строит BVH-дерево (структуру для быстрой проверки коллизий) всей сцены.
    exclude_objs нужен, чтобы исключить сам объект из проверки, 
    иначе он будет сталкиваться сам с собой.
    """
    if exclude_objs is None: exclude_objs = []
    
    # Используем Set имен для быстрого поиска
    exclude_names = {obj.name for obj in exclude_objs}
    
    master_bm = bmesh.new()
    included_count = 0
    
    # Depsgraph — это "оцененное" состояние сцены. Он содержит меши 
    # с уже примененными модификаторами (Subdivision, Deform и т. д.)
    for instance in depsgraph.object_instances:
        obj = instance.object
        
        # Пропускаем свет, камеры и скрытые объекты
        if obj.type != 'MESH' or obj.hide_render:
            continue
        
        if obj.name in exclude_names:
            print(f"DEBUG: Successfully excluded {obj.name} from World BVH") 
            continue
            
        eval_obj = obj.evaluated_get(depsgraph)
        me = eval_obj.to_mesh()
        
        start_idx = len(master_bm.verts)
        master_bm.from_mesh(me)
        
        # Переводим вершины в мировые координаты (World Space)
        mat = instance.matrix_world
        for v in master_bm.verts[start_idx:]:
            v.co = mat @ v.co
            
        eval_obj.to_mesh_clear() # Важно чистить память
        included_count += 1

    if not master_bm.verts:
        master_bm.free()
        return None
        
    bvh = BVHTree.FromBMesh(master_bm)
    master_bm.free()
    return bvh
получаем меш объекта
def get_evaluated_bmesh(obj, depsgraph):
    """Получает честную геометрию объекта с учетом всех деформаций."""
    bm = bmesh.new()
    eval_obj = obj.evaluated_get(depsgraph)
    mesh = eval_obj.to_mesh()
    bm.from_mesh(mesh)
    bm.transform(obj.matrix_world) # Сразу переводим в мировые координаты
    eval_obj.to_mesh_clear()
    return bm
теперь проверить пересечение можно так
depsgraph = bpy.context.evaluated_depsgraph_get() 
bm_asset = get_evaluated_bmesh(asset, depsgraph)
# Немного поднимаем вершины копии объекта вверх
# Иначе объект, стоящий на полу, будет считаться "пересекающим" пол
for v in bm_asset.verts:
    v.co.z += 0.01     
asset_bvh = BVHTree.FromBMesh(bm_asset)
    
overlap = clean_world_bvh.overlap(asset_bvh) if clean_world_bvh else False
bm_asset.free()

if overlap: return False # Объект врезался в стену или другой объект

Эта проверка встраивается в главный цикл (напомню, финальный полный код есть на GitHub).

Проверка устойчивости

Изначально я хотел оценивать, насколько нижняя граница bounding box объекта прилегает к поверхности, но это не всегда работает корректно: например, у стола нижняя граница очень широка, но реальные точки касания — лишь 4 ножки. При этом проверять прямое «касание» мешей бессмысленно, так как между объектами и поверхностями под ними предусмотрено крошечное, но всё же расстояние (см. код проверки пересечений). 

Поэтому здесь удобно снова применить испускание лучей, только теперь из самых нижних точек объекта, строго вниз. Если слишком большой их процент ни с чем не пересечется за первые пару миллиметров — скорее всего, объект слишком сильно «висит в воздухе».

код этой проверки
def check_standing_stability(asset, scene_bvh):
    """Проверяет, не висит ли объект в воздухе (находит точки опоры)."""
    if not scene_bvh: return True
    depsgraph = bpy.context.evaluated_depsgraph_get()
    bm = get_evaluated_bmesh(asset, depsgraph)
    
    all_z = [v.co.z for v in bm.verts]
    if not all_z: 
        bm.free()
        return False
        
    min_z = min(all_z)
    # Берем только вершины, которые находятся в самом низу (в пределах 1см от дна)
    foot_verts = [v.co.copy() for v in bm.verts if v.co.z <= (min_z + 0.01)]
    
    # Оптимизация: если вершин слишком много, берем случайную выборку
    if len(foot_verts) > 20: foot_verts = random.sample(foot_verts, 20)
    
    supported = 0
    for v_pos in foot_verts:
        # Пускаем короткий луч (5 см) вниз из каждой "ноги" объекта.
        # Если луч во что-то попал — значит есть опора.
        hit, loc, norm, idx = scene_bvh.ray_cast(v_pos + Vector((0,0,0.02)), Vector((0,0,-1)), 0.05)
        if hit: supported += 1
        
    bm.free()
    if not foot_verts: return False
    return (supported / len(foot_verts)) >= STABILITY_REQ_PERCENT

Проверка видимости

Наконец важно удостовериться, чтобы объект был хорошо виден на камеру. Простейший способ: пустить луч в камеру из Origin объекта и проверить, пересёк ли он что-то близкое к объекту.

Да, метод не идеален и требует доработки, но на практике работает хорошо, и для MVP сойдёт.

код
def check_standing_stability(asset, scene_bvh):
    """Проверяет, не висит ли объект в воздухе (находит точки опоры)."""
    if not scene_bvh: return True
    depsgraph = bpy.context.evaluated_depsgraph_get()
    bm = get_evaluated_bmesh(asset, depsgraph)
    
    all_z = [v.co.z for v in bm.verts]
    if not all_z: 
        bm.free()
        return False
        
    min_z = min(all_z)
    # Берем только вершины, которые находятся в самом низу (в пределах 1см от дна)
    foot_verts = [v.co.copy() for v in bm.verts if v.co.z <= (min_z + 0.01)]
    
    # Оптимизация: если вершин слишком много, берем случайную выборку
    if len(foot_verts) > 20: foot_verts = random.sample(foot_verts, 20)
    
    supported = 0
    for v_pos in foot_verts:
        # Пускаем короткий луч (5 см) вниз из каждой "ноги" объекта.
        # Если луч во что-то попал — значит есть опора.
        hit, loc, norm, idx = scene_bvh.ray_cast(v_pos + Vector((0,0,0.02)), Vector((0,0,-1)), 0.05)
        if hit: supported += 1
        
    bm.free()
    if not foot_verts: return False
    return (supported / len(foot_verts)) >= STABILITY_REQ_PERCENT

Фотореалистичный рендер

На данном этапе можно быть уверенным, что объекты корректно расставлены по сцене и видны на камеру. Самое время для рендеринга! Однако не достаточно выбрать движок Cycles и поставить больше сэмплов: процесс займёт много времени, а результат будет выглядеть как идеальный 3D-рендер. Такие изображения совсем не похожи на целевое распределение пользовательских фотографий, на них модель не обучишь.

Для минимального решения проблемы достаточно выполнить лишь несколько шагов:

  • настроить DoF и фокусное расстояние камеры (например, на последний добавленный объект);

  • рандомизировать общий вид изображения;

  • ограничить количество сэмплов рендера небольшим числом (например, 128).

Последнее не только симулирует изъяны обывательской фотокамеры зернистостью рендера, но и значительно сокращает процесс (для совсем сильного зерна можно отключить денойз после рендера, но не всегда рекомендую, слишком уж явно).

кусочки кода для всего этого
def setup_camera_optics(cam):
    cd = cam.data
    cd.dof.use_dof = True
    # Случайная диафрагма меняет глубину резкости (размытие фона)
    cd.dof.aperture_fstop = random.uniform(4.0, 11.0)

def setup_color_management(scene):
    # Используем AgX — современный стандарт в Blender для кинематографичной картинки
    # он лучше обрабатывает пересветы, чем старый Filmic
    scene.view_settings.view_transform = 'AgX'
    looks = ['None', 'AgX - Very Low Contrast', 'AgX - Base Contrast', 'AgX - High Contrast', 'AgX - Punchy']
    scene.view_settings.look = random.choice(looks)
    scene.view_settings.exposure = random.uniform(-0.5, 0.5)

# Фокусируемся на первом объекте
cam.data.dof.focus_distance = (cam.location - generated_assets[0].location).length

Однако я рекомендую не ограничиваться минимальными шагами, и добавить разнообразия и фотореализма снимкам более серьезными методами — посредством аддона Photographer 5. Это решение предоставляет профессиональный инструментарий для тончайшей настройки фотореалистичной камеры и освещения (мы ограничимся камерой).

код для Photographer
def apply_photographer_realism(cam):
    # Если установлен аддон Photographer — используем его для фотореализма
    # если нет — этот блок пропустится
    cd = cam.data
    if hasattr(cd, "photographer"):
        cd.photographer.exposure_enabled = True
        cd.photographer.ev = random.uniform(9, 11.5)  # экспозиция
        cd.photographer.wb_enabled = True
        cd.photographer.color_temperature = random.randint(4000, 7500)  # температура (от холодной до тёплой)

    # Добавляем оптические искажения, зерно, аберрации
    if hasattr(cd, "post_effects"):
        pe = cd.post_effects
        pe.post_effects_enabled = True
        pe.lens_distortion = False # Дисторсию лучше выкл, чтобы маски идеально совпадали
        pe.lateral_ca = True
        pe.lateral_ca_amount = random.uniform(0.1, 0.5)
        pe.bloom = True
        pe.bloom_amount = random.uniform(0.05, 0.2)
        pe.film_grain = True
        pe.film_grain_amount = random.uniform(0.1, 0.5)
        pe.lens_vignetting = True
        pe.lens_vignetting_amount = random.uniform(0.1, 0.8)

Замечание: в качестве завершающего штриха, рекомендую задуматься над процедурным добавлением произвольных изъянов: пыли, грязи, отпечатков, царапин — например, отлично подойдет аддон One Click Age.

В принципе, уже можно рендерить:

настраиваем движок
def configure_render_engine(scene):
    # Принудительно включаем GPU, иначе рендер затянется
    scene.render.engine = 'CYCLES'
    scene.render.resolution_percentage = 100
    scene.cycles.device = 'GPU'
    scene.cycles.samples = 200
    scene.cycles.use_denoising = False # Иногда без денойза формируется реалистичная рябь, иногда её слишком много - всё на ваше усмотрение
    
    # Включаем Metal/CUDA/OptiX через API
    prefs = bpy.context.preferences.addons['cycles'].preferences
    prefs.compute_device_type = 'METAL' # Или 'CUDA'/'OPTIX'
    prefs.refresh_devices()
    for d in prefs.devices: d.use = (d.type == 'METAL')
рендерим
def render_pass(scene, folder, img_name):
    print(f"Rendering {img_name}...")
    scene.render.filepath = os.path.join(folder, f"{img_name}.png")
    bpy.ops.render.render(write_still=True)

Метаданные

Фотореалистичный рендер готов — но не им единым! Синтетические данные в трехмерной среде дают огромное преимущество наличием подробной информации о них:

  • имя, координаты, вращение и габариты объекта — помогут, например, вычислить расположение любого объекта относительно других объектов на рендере, что при помощи математики, регулярных выражений (и, по желанию, LLM) можно превратить в очень точные и гарантированно корректные описания изображений;

  • видимость объекта, степень размытости, на чём этот объект стоит — всё это может помочь, например, автоматически определять оптимальные операции редактирования.

Все эти данные тривиально собираются и сохраняются в .json для каждого рендера:

сбор метаданных для одного ассета
def get_detailed_metadata(scene, cam, asset, surface_obj, generated_assets):
    """Сбор расширенной статистики (видимость в пикселях, размытие)."""
    dist = (cam.location - asset.location).length
    
    # Расчет процента видимости (Vis %)
    # Бросаем лучи в 50 случайных точек объекта
    depsgraph = bpy.context.evaluated_depsgraph_get()
    bm = get_evaluated_bmesh(asset, depsgraph)
    verts = [v.co for v in random.sample(list(bm.verts), min(len(bm.verts), 50))]
    bm.free()
    
    vis_count = 0
    origin = cam.matrix_world.translation
    for v in verts:
        # Проецируем 3D точку на 2D экран
        co_2d = world_to_camera_view(scene, cam, v)
        if 0 <= co_2d.x <= 1 and 0 <= co_2d.y <= 1 and co_2d.z > 0:
            direction = (v - origin).normalized()
            hit, loc, _, _, obj, _ = scene.ray_cast(depsgraph, origin + direction * 0.01, direction)
            if hit and obj == asset: vis_count += 1
            
    vis_pct = vis_count / 50.0

    # Расчет физического размытия (Circle of Confusion)
    # Позволяет понять, насколько объект "в фокусе"
    cd = cam.data
    f = cd.lens / 1000
    S1 = cd.dof.focus_distance
    N = cd.dof.aperture_fstop
    coc = 0
    if dist > 0 and N > 0:
        # Формула CoC для тонкой линзы
        coc = abs(dist - S1) * (f**2 / (N * (S1 - f) * dist)) * 1000

    return {
        "name": asset.name,
        "id_color_val": asset.pass_index * 0.1,
        "world_pos": list(asset.location),
        "rotation": list(asset.rotation_euler),
        "dimensions": list(asset.dimensions),
        "distance_from_cam": round(dist, 3),
        "blur_score": coc,
        "visibility_percent": round(vis_pct, 3),
        "stands_on": surface_obj.name if surface_obj else "None",
        "stands_on_generated": surface_obj in generated_assets if surface_obj else False
    }

# для каждого объекта можно добавить так
meta = get_detailed_metadata(scene, cam, asset, surface_obj, generated_assets)
                    asset_metadata.append(meta)
добор данных от камеры и сохранение
# Сохранение полного JSON описания сцены
meta_payload = {
    "image": {"res_x": scene.render.resolution_x, "res_y": scene.render.resolution_y},
    "camera": collect_camera_metadata(cam),
    "assets": asset_metadata
}
with open(os.path.join(folder_path, "metadata.json"), 'w') as f:
    json.dump(meta_payload, f, indent=4)
пример, как выглядят метаданные для сцены
{
    "image": {
        "res_x": 920,
        "res_y": 736
    },
    "camera": {
        "name": "Camera",
        "pos": [
            -0.6153236627578735,
            -4.08074951171875,
            1.9349756240844727
        ],
        "view_dir": [
            0.0,
            0.9999999403953552,
            3.5762786865234375e-07
        ],
        "focal_length": 44.661949157714844,
        "photographer_settings": {
            "ev": 10.386054992675781,
            "wb": 4596,
            "dist": -0.07254987955093384,
            "grain": 0.29828357696533203
        }
    },
    "assets": [
        {
            "name": "cgaxis_models_12_17_fbx",
            "id_color_val": 0.1,
            "world_pos": [
                -0.6821287274360657,
                1.3659605979919434,
                0.4741405248641968
            ],
            "rotation": [
                -4.371138828673793e-08,
                0.0,
                5.040481090545654
            ],
            "dimensions": [
                0.4367552101612091,
                0.24260622262954712,
                0.552066445350647
            ],
            "distance_from_cam": 5.64,
            "blur_score": 0.000125962336163139,
            "visibility_percent": 1.0,
            "stands_on": "Table",
            "stands_on_generated": false
        },
        {
            "name": "cgaxis_models_12_04_fbx",
            "id_color_val": 0.2,
            "world_pos": [
                -0.9981926083564758,
                1.3929975032806396,
                0.47414058446884155
            ],
            "rotation": [
                1.5707964897155762,
                -0.174532949924469,
                0.08045358210802078
            ],
            "dimensions": [
                0.2137666940689087,
                0.4516408443450928,
                0.44565635919570923
            ],
            "distance_from_cam": 5.678,
            "blur_score": 0.00013887709611657694,
            "visibility_percent": 1.0,
            "stands_on": "Table",
            "stands_on_generated": false
        },
        {
            "name": "cgaxis_models_12_26_fbx",
            "id_color_val": 0.30000000000000004,
            "world_pos": [
                1.3239257335662842,
                2.8585124015808105,
                0.7547652125358582
            ],
            "rotation": [
                -4.371138828673793e-08,
                0.0,
                5.137721061706543
            ],
            "dimensions": [
                0.09379471838474274,
                0.10152263939380646,
                0.08520714938640594
            ],
            "distance_from_cam": 7.301,
            "blur_score": 0.008729723458718884,
            "visibility_percent": 1.0,
            "stands_on": "pillow3.001",
            "stands_on_generated": false
        },
        {
            "name": "cgaxis_models_23_01_fbx",
            "id_color_val": 0.4,
            "world_pos": [
                -2.446713447570801,
                3.0233147144317627,
                0.7545179128646851
            ],
            "rotation": [
                1.5707963705062866,
                5.960463766996327e-08,
                3.2980384826660156
            ],
            "dimensions": [
                0.19027948379516602,
                0.25275787711143494,
                0.4897867739200592
            ],
            "distance_from_cam": 7.431,
            "blur_score": 0.009253692660030104,
            "visibility_percent": 0.96,
            "stands_on": "pillow3.001",
            "stands_on_generated": false
        },
        {
            "name": "cgaxis_models_20_30_fbx",
            "id_color_val": 0.5,
            "world_pos": [
                0.6188240051269531,
                1.7577447891235352,
                0.49140465259552
            ],
            "rotation": [
                -4.371138828673793e-08,
                0.0,
                0.8030605316162109
            ],
            "dimensions": [
                0.3922857940196991,
                0.2607516944408417,
                0.40199998021125793
            ],
            "distance_from_cam": 6.14,
            "blur_score": 0.0030432178449578753,
            "visibility_percent": 1.0,
            "stands_on": "pillow2",
            "stands_on_generated": false
        },
        {
            "name": "cgaxis_models_13_06_fbx",
            "id_color_val": 0.6000000000000001,
            "world_pos": [
                0.3568503260612488,
                1.9026751518249512,
                0.49140650033950806
            ],
            "rotation": [
                -0.015366598963737488,
                0.1738620102405548,
                1.3932098150253296
            ],
            "dimensions": [
                0.9827837944030762,
                0.7916317582130432,
                1.4945673942565918
            ],
            "distance_from_cam": 6.231,
            "blur_score": 0.0035696532970445373,
            "visibility_percent": 0.96,
            "stands_on": "pillow2",
            "stands_on_generated": false
        },
        {
            "name": "cgaxis_models_13_14_fbx",
            "id_color_val": 0.7000000000000001,
            "world_pos": [
                -0.7396146059036255,
                1.3708271980285645,
                1.0241405963897705
            ],
            "rotation": [
                -4.371138828673793e-08,
                0.0,
                0.7819402813911438
            ],
            "dimensions": [
                0.13121876120567322,
                0.11774390190839767,
                0.13382279872894287
            ],
            "distance_from_cam": 5.529,
            "blur_score": 0.000907712947300288,
            "visibility_percent": 1.0,
            "stands_on": "cgaxis_models_12_17_fbx",
            "stands_on_generated": true
        },
        {
            "name": "cgaxis_models_20_11_fbx",
            "id_color_val": 0.8,
            "world_pos": [
                -2.455188751220703,
                3.533297538757324,
                0.7496016621589661
            ],
            "rotation": [
                -4.304731504589654e-08,
                7.59040386100196e-09,
                5.177826404571533
            ],
            "dimensions": [
                0.39705926179885864,
                0.3958461284637451,
                0.4185551702976227
            ],
            "distance_from_cam": 7.922,
            "blur_score": 0.011086610876650898,
            "visibility_percent": 0.66,
            "stands_on": "pillow3.001",
            "stands_on_generated": false
        }
    ]
}

Конечно, данных можно собрать гораздо больше, если есть ещё какие-либо потребности.

Карты

Кроме того, можно сформировать и сохранить очень ценную визуальную информацию о рендере: точную карту глубины и сегментационную карту всех добавленных объектов. Эти данные сформировать уже не так просто: здесь потребуется немного покодить, и оформить цепочку из нескольких нод.

функция для этого
def setup_compositor(scene, cam, folder_path):
    # Настраиваем ноды композитора для автоматического сохранения масок при рендере
    scene.use_nodes = True
    tree = scene.node_tree
    nodes = tree.nodes
    links = tree.links
    
    # Чистим старые авто-ноды, чтобы не плодить дубликаты при перезапусках
    for n in list(nodes):
        if n.name.startswith("_AUTO_"): nodes.remove(n)

    # 1. Render Layers (источник данных)
    rl = next((n for n in nodes if n.type == 'R_LAYERS'), None)
    if not rl: 
        rl = nodes.new('CompositorNodeRLayers')
        rl.name = "_AUTO_RLayers"
        
    # Включаем нужные пассы (Passes)
    scene.view_layers[0].use_pass_z = True             # Карта глубины
    scene.view_layers[0].use_pass_object_index = True  # Индексы объектов для сегментации

    # 2. Output (куда сохранять файлы)
    file_out = nodes.new('CompositorNodeOutputFile')
    file_out.name = "_AUTO_Output"
    file_out.base_path = folder_path
    file_out.file_slots.clear()
    file_out.file_slots.new("depth_map")
    file_out.file_slots.new("segmentation_map")

    # 3. Depth (Нормализация)
    # Сырой Z-buffer выдает значения в метрах (например, от 0.1 до 100).
    # Нормализуем их в 0-1, чтобы получить корректную ч/б картинку.
    norm_node = nodes.new('CompositorNodeNormalize')
    norm_node.name = "_AUTO_DepthNorm"
    links.new(rl.outputs['Depth'], norm_node.inputs[0])
    links.new(norm_node.outputs[0], file_out.inputs[0])

    # 4. Segmentation (Трюк с ColorRamp)
    # Чтобы превратить ID объекта (1, 2, 3...) в конкретный цвет:
    math_round = nodes.new('CompositorNodeMath')
    math_round.name = "_AUTO_SegRound"
    math_round.operation = 'ROUND'
    
    math_div = nodes.new('CompositorNodeMath')
    math_div.name = "_AUTO_SegDiv"
    math_div.operation = 'DIVIDE'
    math_div.inputs[1].default_value = 10.0 # Делитель должен совпадать с шагом рампы
    
    c_ramp = nodes.new('CompositorNodeValToRGB')
    c_ramp.name = "_AUTO_SegRamp"
    c_ramp.color_ramp.interpolation = 'CONSTANT' # CONSTANT убирает градиенты на границах объектов
    elements = c_ramp.color_ramp.elements
    
    # Сбрасываем и заполняем градиент нашими цветами
    while len(elements) > 1: elements.remove(elements[-1])
    elements[0].position = 0.0
    elements[0].color = (0, 0, 0, 1) # Фон (0)
    
    for i, col in enumerate(SEGMENT_COLORS):
        pos = ((i + 1) - 0.5) / 10.0 
        e = elements.new(pos)
        e.color = col

    # Цепочка: IndexOB -> Округление -> Деление -> ColorRamp -> Файл
    links.new(rl.outputs['IndexOB'], math_round.inputs[0])
    links.new(math_round.outputs[0], math_div.inputs[0])
    links.new(math_div.outputs[0], c_ramp.inputs[0])
    links.new(c_ramp.outputs[0], file_out.inputs[1])

    return file_out

Таким образом, для каждого изображения вместе с рендером будет формироваться карта глубины и сегментационная маска:

карта глубины, полностью см. GitHub
карта глубины, полностью см. GitHub
сегментационная маска, полностью см. GitHub
сегментационная маска, полностью см. GitHub

Редактирование

Поскольку камера уже настроена и объекты расставлены, сбор данных на редактирование изображений очень прост: достаточно лишь что-то изменить на сцене и повторить рендер.

Самые простые операции, требующие минимальной реализации — добавление/удаление объекта, поворот вокруг оси Z, смена размеров, перемещение. Все они реализуются несложно, при этом позволяя собирать очень ценные данные для обучения современных моделей редактирования. 

На этом этапе уже можно собирать полноценную main-функцию:

Подготовка к рендеру, как и раньше
«Прячем» все объекты и рендерим чистую сцену
Вновь «показываем» все объекты, стандартный рендер
Для каждого объекта:
    Пробуем вращать, перемещать, менять размер, проверяя корректность
    Если ничего не вышло, удаляем (гарантированно корректная операция)
    В любом случае, +1 рендер

Примеры редактирований (полные рендеры и м��таданные см. GitHub):

Удаление чашки с приборами
Удаление чашки с приборами
Перенос вентилятора с пола на диван
Перенос вентилятора с пола на диван
Смена журнального стенда на мини-вентилятор
Смена журнального стенда на мини-вентилятор
Удаление кулера
Удаление кулера

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

  1. в исходном порядке (до удаления → «удали объект» → после удаления)

  2. в обратном порядке, просто поменяв местами (после удаления → «добавь объект» → до удаления)

  3. перемешав редактирования (один вариант редактирования → «сделай то-то и то-то» → другой вариант редактирования)

Когда такие пары редактирования собираются посредством генеративного ИИ, подобное инвертирование и перемешивание имеют пагубные эффекты, поскольку оригинал и редактирование отличаются с точки зрения шума, качества и детализации. В нашем же случае все рендеры консистентны и одинаково качественны, в каком порядке их не бери.

Текст

Поскольку для каждого объекта сохранены значительные метаданные (как минимум, имя и точные координаты) — эти данные можно пересчитать во взаимные расположения объектов относительно камеры. Например, автоматически узнать, что один объект расположен в правой стороне кадра и достаточно далеко, а второй — слева и очень близко. Эта информация может быть очень полезна как минимум двумя способами:

  1. Достаточно разработать небольшой набор регулярных выражений — и подобная информация превратится в точное описание изображения (всех объектов на нём). По желанию можно прогнать такие синтетические описания через любую LLM, для придания им большей реалистичности и разнообразия.

  2. Аналогично можно сформировать инструкции редактирования. Пространственная информация позволяет даже заменить непосредственные имена объектов на их расположение (например, «Удали этот объект сзади справа»), что очень полезно для понимания моделью, и естественно для человеческой речи.

Этот функционал ещё не реализован, но планируется в ближайшем будущем.

Итого

Итак, повторюсь, весь код (версия с англ. и ру. комментариями, вместе с инструкциями по запуску, описанием и примерами) лежит на гитхабе: https://github.com/georfed/BLIMP 

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

Конечно, есть много недоработок, и большой потенциал к развитию. Скорее всего, скрипт будет совершенствоваться со временем, так что если интересно — поставьте звёздочку на репу)

Спасибо за прочтение, отзывы и предложения пишите в комментарии — буду читать и отвечать!

Скрытый текст

Наш тг-канал — пишем редко, но метко: https://t.me/layercv 

Также, я учусь в отличной магистратуре ИТМО AI Talent Hub — приглашаю и вас заглянуть.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как считаете, есть ли за 3D-синтетикой будущее визуальных данных?
50%Однозначно!4
0%Нет, не похоже на реальные фото…0
50%Для некоторых задач точно подойдёт4
Проголосовали 8 пользователей. Воздержался 1 пользователь.