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

Поэтому я решил собрать собственный контролируемый пайплайн. Базой для эксперимента была выбрана Stable Diffusion 1.5. Да, это далеко не самая новая архитектура: она хуже современных моделей понимает комплексные описания, чаще ломает анатомию и ограничена базовым разрешением 512×512. Но у неё остаются неоспоримые козыри: низкие требования к железу, гигантская экосистема (ControlNet, чекпоинты) и возможность быстро обучать и тестировать гипотезы на домашнем ПК.

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

Сбор датасета

Изображения были вручную отобраны с сайта stickersvk.com. Сайт сообщает, что все права на стикеры принадлежат их авторам.

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

  • Во‑вторых, обучение модели будет производиться исключительно в образовательных целях, полученная LoRA не будет использоваться в коммерческих и каких‑либо других целях, кроме исследовательских.

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

При сборе датасета было важно не дать модели возможности запомнить один конкретный стикерпак. Если взять много изображений одного персонажа, LoRA с большой вероятностью начнёт воспроизводить не абстрактный стиль, а его внешность, характерные пропорции и повторяющиеся элементы дизайна.

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

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

Слева – стикеры, попавшие в датасет; справа – варианты, которые были исключены
Слева — стикеры, попавшие в датасет; справа — варианты, которые были исключены

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

Изначально стикеры имели прозрачный фон. Для обучения было необходимо заменить его на белый и преобразовать изображения в JPG.

Gemini 3.1 Pro написал программу, которая добавила белый фон каждому стикеру и сохранила в JPG. Считаю приемлемым использование генеративного ИИ в работе, если это ускоряет процесс разработки, сокращает объём рутины и служит лишь инструментом реализации человеческой идеи.

Экономим время и делегируем создание инструмента нейросети
Экономим время и делегируем создание инструмента нейросети
Скрипт (Python): Замена прозрачного фона на белый

Инструмент: сгенерировано в Gemini 3.1 Pro. Скрипт берет изображения с альфа‑каналом и аккуратно накладывает их на белый фон, сохраняя в JPG.

pip install pillow
import os
from PIL import Image

def add_white_background(input_dir, output_dir):
    # Создаем папку для сохранения, если ее нет
    if not os.path.exists(output_dir):
        os.makedirs(output_dir)

    # Поддерживаемые форматы (можно расширить)
    valid_extensions = ('.png', '.webp', '.jpg', '.jpeg', '.gif', '.bmp')

    for filename in os.listdir(input_dir):
        if not filename.lower().endswith(valid_extensions):
            continue

        input_path = os.path.join(input_dir, filename)
        
        # Сохранять будем в JPG, так как прозрачность нам больше не нужна
        name, _ = os.path.splitext(filename)
        output_filename = f"{name}.jpg"
        output_path = os.path.join(output_dir, output_filename)

        try:
            with Image.open(input_path) as img:
                # Переводим в RGBA, чтобы гарантированно получить альфа-канал (прозрачность)
                img_rgba = img.convert("RGBA")
                
                # Создаем абсолютно белое изображение такого же размера
                white_bg = Image.new("RGBA", img.size, "WHITE")
                
                # Накладываем исходное изображение на белый фон
                # Третий аргумент (img_rgba) используется как маска прозрачности
                white_bg.paste(img_rgba, (0, 0), img_rgba)
                
                # Конвертируем обратно в RGB (без прозрачности) для сохранения в JPEG
                final_img = white_bg.convert("RGB")
                final_img.save(output_path, "JPEG", quality=95)
                
            print(f"✅ Успешно: {filename} -> {output_filename}")
            
        except Exception as e:
            print(f"❌ Ошибка при обработке {filename}: {e}")

if __name__ == "__main__":
    # Укажите здесь пути к вашим папкам
    INPUT_FOLDER = "images"         # Папка с исходными картинками
    OUTPUT_FOLDER = "images_white"  # Папка для готовых картинок

    print("Начинаем обработку...")
    add_white_background(INPUT_FOLDER, OUTPUT_FOLDER)
    print("Готово!")

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

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

Выбор триггера и разметка

Триггером был выбран токен «vkstckrs» как уникальная производная от «VK stickers». Не добавлял имена героев стикерпаков и имена авторов для исключения нарушения авторского права при использовании модели.

Уникальный токен нужен для того, чтобы во время генерации явно активировать обученные признаки. Он не должен быть обычным английским словом с уже существующим значением для текстового энкодера. Иначе поведение LoRA теоретически может смешиваться с тем, что базовая модель знала до обучения.

SD1.5 лучше понимает теговую разметку в Danbooru стиле и короткие фразы. Размечать нужно то, что модель не должна выучить. Учим стиль и белый фон — размечаем содержимое. ChatGPT 5.6 написал программу, которая разметила каждый стикер короткой английской фразой через BLIP.

Разметить 30 картинок вручную не составило бы труда, но созданный сейчас инструмент облегчит разметку в десятках последующих проектов
Разметить 30 картинок вручную не составило бы труда, но созданный сейчас инструмент облегчит разметку в десятках последующих проектов
Скрипт (Python): Автоматический captioning через BLIP

Скрипт локально загружает модель Salesforce/blip-image-captioning-large с Hugging Face и создает .txt файл для каждой картинки. Мы сразу закладываем возможность передать наш триггер vkstckrs через аргументы командной строки.

#!/usr/bin/env python3
"""Автоматическая генерация caption-файлов для датасета LoRA SD 1.5."""

from __future__ import annotations

import argparse
import sys
from pathlib import Path
from typing import Iterable

import torch
from PIL import Image, ImageOps, UnidentifiedImageError
from tqdm import tqdm
from transformers import BlipForConditionalGeneration, BlipProcessor


IMAGE_EXTENSIONS = {
    ".jpg",
    ".jpeg",
    ".png",
    ".webp",
    ".bmp",
    ".tif",
    ".tiff",
    ".gif",
}


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "Создаёт одноимённый .txt-caption рядом с каждым изображением "
            "для обучения LoRA Stable Diffusion 1.5."
        )
    )

    parser.add_argument(
        "folder",
        type=Path,
        help="Папка с изображениями",
    )
    parser.add_argument(
        "--model",
        default="Salesforce/blip-image-captioning-large",
        help="Модель Hugging Face для генерации подписей",
    )
    parser.add_argument(
        "--trigger",
        default="",
        help='Trigger word LoRA, например: "sks_person"',
    )
    parser.add_argument(
        "--prompt",
        default="",
        help='Начало, задаваемое модели, например: "a photo of"',
    )
    parser.add_argument(
        "--prefix",
        default="",
        help="Текст, добавляемый перед сгенерированной подписью",
    )
    parser.add_argument(
        "--suffix",
        default="",
        help="Текст, добавляемый после сгенерированной подписи",
    )
    parser.add_argument(
        "--recursive",
        action="store_true",
        help="Обрабатывать также вложенные папки",
    )
    parser.add_argument(
        "--overwrite",
        action="store_true",
        help="Перезаписывать уже существующие .txt-файлы",
    )
    parser.add_argument(
        "--device",
        choices=("auto", "cuda", "mps", "cpu"),
        default="auto",
        help="Устройство вычислений",
    )
    parser.add_argument(
        "--max-new-tokens",
        type=int,
        default=50,
        help="Максимальная длина генерируемой подписи",
    )
    parser.add_argument(
        "--num-beams",
        type=int,
        default=4,
        help="Число лучей beam search; больше — медленнее",
    )

    return parser.parse_args()


def choose_device(requested: str) -> torch.device:
    if requested == "cuda":
        if not torch.cuda.is_available():
            raise RuntimeError(
                "CUDA запрошена, но недоступна в установленном PyTorch."
            )
        return torch.device("cuda")

    if requested == "mps":
        if not torch.backends.mps.is_available():
            raise RuntimeError("MPS запрошен, но недоступен.")
        return torch.device("mps")

    if requested == "cpu":
        return torch.device("cpu")

    if torch.cuda.is_available():
        return torch.device("cuda")

    if torch.backends.mps.is_available():
        return torch.device("mps")

    return torch.device("cpu")


def find_images(folder: Path, recursive: bool) -> list[Path]:
    iterator: Iterable[Path]

    if recursive:
        iterator = folder.rglob("*")
    else:
        iterator = folder.iterdir()

    return sorted(
        path
        for path in iterator
        if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS
    )


def clean_text(text: str) -> str:
    return " ".join(text.replace("\n", " ").split()).strip(" ,")


def assemble_caption(
    generated: str,
    trigger: str,
    prefix: str,
    suffix: str,
) -> str:
    parts = [
        clean_text(value)
        for value in (trigger, prefix, generated, suffix)
    ]

    return ", ".join(part for part in parts if part)


def prepare_inputs(
    processor: BlipProcessor,
    image: Image.Image,
    prompt: str,
    device: torch.device,
    float_dtype: torch.dtype,
) -> dict[str, torch.Tensor]:
    if prompt:
        batch = processor(
            images=image,
            text=prompt,
            return_tensors="pt",
        )
    else:
        batch = processor(
            images=image,
            return_tensors="pt",
        )

    result: dict[str, torch.Tensor] = {}

    for name, tensor in batch.items():
        tensor = tensor.to(device)

        if torch.is_floating_point(tensor):
            tensor = tensor.to(float_dtype)

        result[name] = tensor

    return result


def main() -> int:
    args = parse_args()
    folder = args.folder.expanduser().resolve()

    if not folder.is_dir():
        print(
            f"Ошибка: папка не найдена: {folder}",
            file=sys.stderr,
        )
        return 2

    if args.max_new_tokens < 1:
        print(
            "Ошибка: --max-new-tokens должен быть больше нуля.",
            file=sys.stderr,
        )
        return 2

    if args.num_beams < 1:
        print(
            "Ошибка: --num-beams должен быть больше нуля.",
            file=sys.stderr,
        )
        return 2

    images = find_images(folder, args.recursive)

    if not images:
        print("Поддерживаемые изображения не найдены.")
        return 0

    try:
        device = choose_device(args.device)
    except RuntimeError as exc:
        print(f"Ошибка: {exc}", file=sys.stderr)
        return 2

    # FP16 ускоряет CUDA. Для CPU и MPS используется FP32.
    model_dtype = (
        torch.float16
        if device.type == "cuda"
        else torch.float32
    )

    print(f"Изображений: {len(images)}")
    print(f"Устройство: {device}")
    print(f"Модель: {args.model}")
    print(
        "При первом запуске веса модели "
        "будут загружены в кэш Hugging Face."
    )

    processor = BlipProcessor.from_pretrained(args.model)

    model = BlipForConditionalGeneration.from_pretrained(
        args.model,
        torch_dtype=model_dtype,
    ).to(device)

    model.eval()

    created = 0
    skipped = 0
    failed: list[tuple[Path, str]] = []

    for image_path in tqdm(
        images,
        desc="Разметка",
        unit="img",
    ):
        caption_path = image_path.with_suffix(".txt")

        if caption_path.exists() and not args.overwrite:
            skipped += 1
            continue

        try:
            with Image.open(image_path) as source:
                image = ImageOps.exif_transpose(source).convert("RGB")

                inputs = prepare_inputs(
                    processor=processor,
                    image=image,
                    prompt=args.prompt,
                    device=device,
                    float_dtype=model_dtype,
                )

            with torch.inference_mode():
                output_ids = model.generate(
                    **inputs,
                    max_new_tokens=args.max_new_tokens,
                    num_beams=args.num_beams,
                )

            generated = processor.decode(
                output_ids[0],
                skip_special_tokens=True,
            )

            caption = assemble_caption(
                generated=generated,
                trigger=args.trigger,
                prefix=args.prefix,
                suffix=args.suffix,
            )

            if not caption:
                raise RuntimeError("модель вернула пустую подпись")

            # Атомарная запись через временный файл.
            temporary_path = caption_path.with_suffix(
                caption_path.suffix + ".tmp"
            )

            temporary_path.write_text(
                caption + "\n",
                encoding="utf-8",
            )

            temporary_path.replace(caption_path)
            created += 1

        except (
            OSError,
            UnidentifiedImageError,
            RuntimeError,
            ValueError,
        ) as exc:
            failed.append((image_path, str(exc)))

    print(f"\nСоздано/обновлено: {created}")
    print(f"Пропущено: {skipped}")
    print(f"Ошибок: {len(failed)}")

    if failed:
        print("\nНеобработанные файлы:", file=sys.stderr)

        for path, error in failed:
            print(f"- {path}: {error}", file=sys.stderr)

        return 1

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Пример полученной разметки на этом этапе: vkstckrs, vector illustration of a cartoon vampire with red eyes and black hair

Логика здесь обратная той, которая может показаться очевидной на первый взгляд. Если определённый признак явно описывается в каждом caption, модель свяжет его с соответствующими словами, а не с уникальным триггером. Если же общий для датасета стиль не описывать, он начинает ассоциироваться именно с триггером «vkstckrs». Другими словами, размечаем (описываем) то, что нейросеть НЕ должна выучить и добавляем токен (тиггер), куда нейросеть должна «записать» всё остальное.

Автоматическая разметка позволила быстро получить первоначальные текстовые файлы, но её нельзя было использовать без проверки.

Заметил, что в описаниях встречаются ошибки, также некоторые описания содержали описание стиля «vector illustration of a cartoon [subject]». Так как ChatGPT предусмотрел в своей программе добавление промпта, влияющего на разметку, было решено прописать в него это повторяющееся описание стиля, понятное модели BLIP. Это исключило вариативность описания стиля, которую пришлось бы чистить руками. Теперь, когда все файлы разметки содержат одинаковую подстроку, ее можно удалить. Все описания объектов нужно проверить и исправить ошибки.

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

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

BLIP оставил косяки, от которых обязательно нужно избавиться
BLIP оставил косяки, от которых обязательно нужно избавиться

Ошибки BLIP особенно заметны на стилизованных и сильно упрощённых изображениях. Модель может неверно определить животное, перепутать предметы или додумать отсутствующие элементы. Поэтому ручная проверка остаётся обязательной даже при автоматизации captioning.

Gemini написал программу, которая удалила подстроку «vector illustration of a cartoon » из всех файлов разметки. После удаления подстроки я ещё раз просмотрел все текстовые файлы и проверил, что предложения остались грамматически понятными, а описание субъекта не потерялось.

Скрипт (Python): Удаление подстроки из.txt файлов

Утилитарный скрипт, который проходит по всем файлам датасета и вычищает повторяющийся стиль, оставляя только описание субъекта.

import os
from pathlib import Path

def remove_substring_from_files(folder_path, target_substring):
    # Преобразуем путь в объект Path
    folder = Path(folder_path)
    
    # Проверяем, существует ли папка
    if not folder.is_dir():
        print(f"Ошибка: Папка '{folder_path}' не найдена.")
        return

    # Ищем все .txt файлы в папке (если нужны другие форматы, измените "*.txt" на "*.*")
    # Если нужно искать и в подпапках, используйте rglob("*.txt") вместо glob
    txt_files = list(folder.glob("*.txt"))
    
    if not txt_files:
        print("В указанной папке нет текстовых файлов.")
        return

    processed_count = 0
    
    for file_path in txt_files:
        try:
            # Читаем содержимое файла
            with open(file_path, 'r', encoding='utf-8') as file:
                content = file.read()
            
            # Если подстрока есть в тексте, заменяем её на пустоту
            if target_substring in content:
                new_content = content.replace(target_substring, "")
                
                # Перезаписываем файл с новым содержимым
                with open(file_path, 'w', encoding='utf-8') as file:
                    file.write(new_content)
                
                print(f"Обновлен файл: {file_path.name}")
                processed_count += 1
                
        except Exception as e:
            print(f"Ошибка при обработке файла {file_path.name}: {e}")

    print(f"\nГотово! Изменено файлов: {processed_count} из {len(txt_files)}")

# --- ИСПОЛЬЗОВАНИЕ ---

# Укажите путь к вашей папке с файлами. 
# Обратите внимание на букву 'r' перед строкой пути для Windows (чтобы слеши читались корректно)
DIRECTORY_PATH = r"images_white" 

# Точная строка для удаления (скопирована из вашего запроса)
STRING_TO_REMOVE = "vector illustration of a cartoon "

remove_substring_from_files(DIRECTORY_PATH, STRING_TO_REMOVE)

Итоговый вид разметки (то, что пошло в обучение): vkstckrs, vampire with red eyes and black hair

Итоговая структура датасета была простой: JPG‑изображение и одноимённый текстовый файл с коротким англоязычным описанием.

Подготовка к обучению

Платформой обучения был выбран OneTrainer. SD1.5 является простой моделью, не требующей в моем кейсе тонкой настройки через kohya_ss, который является стандартом обучения моделей семейства Stable Diffuison. Было отдано предпочтение удобству интерфейса и автоматизации процесса.

OneTrainer позволяет настроить датасет, параметры LoRA, оптимизатор, сохранение промежуточных версий и создание сэмплов во время обучения через графический интерфейс. Для небольшого стилевого эксперимента этого более чем достаточно.

Установка прошла через официальный репозиторий GitHub и файл install.bat.

После клонирования репозитория достаточно запустить установочный файл и дождаться создания окружения и загрузки зависимостей. Процесс может занять время в зависимости от скорости интернета и доступности некоторых пакетов в текущих условиях фильтрации трафика.

Для обучения использовались следующие основные параметры. Так как датасет небольшой, обучение заняло всего 100 эпох.

  • Модель: Stable Diffusion 1.5 (stable-diffusion-v1-5)

  • Оптимизатор: AdamW (с включенным use_schedulefree: true)

  • Learning Rate: 1e-4 (0.0001) для UNet и 1e-5 (0.00001) для Text Encoder.

  • LoRA Rank (Dim) / Alpha: 32 / 16

  • Batch Size: 1

  • Resolution: 512×512

  • Precision: FP16 (Mixed Precision)

  • Эпохи: 100 (сохранение каждые 100 шагов)

Полный конфиг OneTrainer (.json)

Этот файл можно импортировать в OneTrainer, чтобы полностью повторить настройки моего эксперимента.

{
    "__version": 11,
    "training_method": "LORA",
    "model_type": "STABLE_DIFFUSION_15",
    "debug_mode": false,
    "debug_dir": "debug",
    "workspace_dir": "workspace/run",
    "cache_dir": "workspace-cache/run",
    "huggingface_cache_dir": "",
    "offline_mode": false,
    "tensorboard": true,
    "tensorboard_expose": false,
    "tensorboard_always_on": false,
    "tensorboard_port": 6006,
    "validation": false,
    "validate_after": 1,
    "validate_after_unit": "EPOCH",
    "continue_last_backup": false,
    "prevent_overwrites": false,
    "include_train_config": "NONE",
    "multi_gpu": false,
    "device_indexes": "",
    "gradient_reduce_precision": "FLOAT_32_STOCHASTIC",
    "fused_gradient_reduce": true,
    "async_gradient_reduce": true,
    "async_gradient_reduce_buffer": 100,
    "base_model_name": "stable-diffusion-v1-5/stable-diffusion-v1-5",
    "output_dtype": "FLOAT_32",
    "output_model_format": "KOHYA_LORA",
    "output_model_destination": "models/lvkstckrs.safetensors",
    "async_offloading": true,
    "force_circular_padding": false,
    "compile": false,
    "concept_file_name": "training_concepts/concepts.json",
    "concepts": [
        {
            "__version": 2,
            "image": {
                "__version": 0,
                "enable_crop_jitter": true,
                "enable_random_flip": false,
                "enable_fixed_flip": false,
                "enable_random_rotate": false,
                "enable_fixed_rotate": false,
                "random_rotate_max_angle": 0.0,
                "enable_random_brightness": false,
                "enable_fixed_brightness": false,
                "random_brightness_max_strength": 0.0,
                "enable_random_contrast": false,
                "enable_fixed_contrast": false,
                "random_contrast_max_strength": 0.0,
                "enable_random_saturation": false,
                "enable_fixed_saturation": false,
                "random_saturation_max_strength": 0.0,
                "enable_random_hue": false,
                "enable_fixed_hue": false,
                "random_hue_max_strength": 0.0,
                "enable_resolution_override": false,
                "resolution_override": "512",
                "enable_random_circular_mask_shrink": false,
                "enable_random_mask_rotate_crop": false
            },
            "text": {
                "__version": 0,
                "prompt_source": "sample",
                "prompt_path": "",
                "enable_tag_shuffling": false,
                "tag_delimiter": ",",
                "keep_tags_count": 1,
                "tag_dropout_enable": false,
                "tag_dropout_mode": "FULL",
                "tag_dropout_probability": 0.0,
                "tag_dropout_special_tags_mode": "NONE",
                "tag_dropout_special_tags": "",
                "tag_dropout_special_tags_regex": false,
                "caps_randomize_enable": false,
                "caps_randomize_mode": "capslock, title, first, random",
                "caps_randomize_probability": 0.0,
                "caps_randomize_lowercase": false
            },
            "name": "main",
            "path": "C:/Users/arkin/Downloads/stickers/images_white",
            "seed": 949656464,
            "enabled": true,
            "type": "STANDARD",
            "include_subdirectories": false,
            "image_variations": 1,
            "text_variations": 1,
            "balancing": 1.0,
            "balancing_strategy": "REPEATS",
            "loss_weight": 1.0,
            "concept_stats": {}
        }
    ],
    "aspect_ratio_bucketing": true,
    "latent_caching": true,
    "clear_cache_before_training": true,
    "learning_rate_scheduler": "CONSTANT",
    "custom_learning_rate_scheduler": null,
    "scheduler_params": [],
    "learning_rate": 0.0001,
    "learning_rate_warmup_steps": 200.0,
    "learning_rate_cycles": 1.0,
    "learning_rate_min_factor": 0.0,
    "epochs": 100,
    "batch_size": 1,
    "gradient_accumulation_steps": 1,
    "ema": "OFF",
    "ema_decay": 0.999,
    "ema_update_step_interval": 5,
    "dataloader_threads": 2,
    "train_device": "cuda",
    "temp_device": "cpu",
    "train_dtype": "FLOAT_16",
    "fallback_train_dtype": "BFLOAT_16",
    "enable_autocast_cache": true,
    "only_cache": false,
    "resolution": "512",
    "frames": "25",
    "attention_mechanism": "SDP",
    "mse_strength": 1.0,
    "mae_strength": 0.0,
    "log_cosh_strength": 0.0,
    "huber_strength": 0.0,
    "huber_delta": 1.0,
    "vb_loss_strength": 1.0,
    "loss_weight_fn": "CONSTANT",
    "loss_weight_strength": 5.0,
    "dropout_probability": 0.01,
    "loss_scaler": "NONE",
    "learning_rate_scaler": "NONE",
    "clip_grad_norm": 1.0,
    "offset_noise_weight": 0.0,
    "generalized_offset_noise": false,
    "perturbation_noise_weight": 0.0,
    "rescale_noise_scheduler_to_zero_terminal_snr": false,
    "force_v_prediction": false,
    "force_epsilon_prediction": false,
    "min_noising_strength": 0.0,
    "max_noising_strength": 1.0,
    "timestep_distribution": "UNIFORM",
    "noising_weight": 0.0,
    "noising_bias": 0.0,
    "timestep_shift": 1.0,
    "dynamic_timestep_shifting": false,
    "unet": {
        "__version": 0,
        "model_name": "",
        "include": true,
        "train": true,
        "stop_training_after": null,
        "stop_training_after_unit": "NEVER",
        "learning_rate": 0.0001,
        "weight_dtype": "FLOAT_16",
        "dropout_probability": 0.0,
        "train_embedding": true,
        "attention_mask": false,
        "guidance_scale": 1.0,
        "gradient_checkpointing": true,
        "offload_fraction": 0.0,
        "activation_offloading": false
    },
    "prior": {
        "__version": 0,
        "model_name": "",
        "include": true,
        "train": true,
        "stop_training_after": 0,
        "stop_training_after_unit": "NEVER",
        "learning_rate": null,
        "weight_dtype": "FLOAT_32",
        "dropout_probability": 0.0,
        "train_embedding": true,
        "attention_mask": false,
        "guidance_scale": 1.0,
        "gradient_checkpointing": true,
        "offload_fraction": 0.0,
        "activation_offloading": false
    },
    "transformer": {
        "__version": 0,
        "model_name": "",
        "include": true,
        "train": true,
        "stop_training_after": 0,
        "stop_training_after_unit": "NEVER",
        "learning_rate": null,
        "weight_dtype": "FLOAT_32",
        "dropout_probability": 0.0,
        "train_embedding": true,
        "attention_mask": false,
        "guidance_scale": 1.0,
        "gradient_checkpointing": true,
        "offload_fraction": 0.0,
        "activation_offloading": false
    },
    "unconditional_transformer": {
        "__version": 0,
        "model_name": "",
        "include": true,
        "train": false,
        "stop_training_after": null,
        "stop_training_after_unit": "NEVER",
        "learning_rate": null,
        "weight_dtype": "FLOAT_32",
        "dropout_probability": 0.0,
        "train_embedding": true,
        "attention_mask": false,
        "guidance_scale": 1.0,
        "gradient_checkpointing": false,
        "offload_fraction": 0.0,
        "activation_offloading": false
    },
    "quantization": {
        "__version": 0,
        "layer_filter": "",
        "layer_filter_preset": "full",
        "layer_filter_regex": false,
        "svd_dtype": "NONE",
        "svd_rank": 16,
        "cache_dir": "workspace-cache/run/quantization"
    },
    "text_encoder": {
        "__version": 0,
        "model_name": "",
        "include": true,
        "train": true,
        "stop_training_after": null,
        "stop_training_after_unit": "NEVER",
        "learning_rate": 1e-05,
        "weight_dtype": "FLOAT_16",
        "dropout_probability": 0.0,
        "train_embedding": true,
        "attention_mask": false,
        "guidance_scale": 1.0,
        "gradient_checkpointing": true,
        "offload_fraction": 0.0,
        "activation_offloading": false
    },
    "text_encoder_layer_skip": 0,
    "text_encoder_sequence_length": 512,
    "text_encoder_2": {
        "__version": 0,
        "model_name": "",
        "include": true,
        "train": true,
        "stop_training_after": 30,
        "stop_training_after_unit": "EPOCH",
        "learning_rate": null,
        "weight_dtype": "FLOAT_32",
        "dropout_probability": 0.0,
        "train_embedding": true,
        "attention_mask": false,
        "guidance_scale": 1.0,
        "gradient_checkpointing": true,
        "offload_fraction": 0.0,
        "activation_offloading": false
    },
    "text_encoder_2_layer_skip": 0,
    "text_encoder_2_sequence_length": 77,
    "text_encoder_3": {
        "__version": 0,
        "model_name": "",
        "include": true,
        "train": true,
        "stop_training_after": 30,
        "stop_training_after_unit": "EPOCH",
        "learning_rate": null,
        "weight_dtype": "FLOAT_32",
        "dropout_probability": 0.0,
        "train_embedding": true,
        "attention_mask": false,
        "guidance_scale": 1.0,
        "gradient_checkpointing": true,
        "offload_fraction": 0.0,
        "activation_offloading": false
    },
    "text_encoder_3_layer_skip": 0,
    "text_encoder_4": {
        "__version": 0,
        "model_name": "",
        "include": true,
        "train": true,
        "stop_training_after": 30,
        "stop_training_after_unit": "EPOCH",
        "learning_rate": null,
        "weight_dtype": "FLOAT_32",
        "dropout_probability": 0.0,
        "train_embedding": true,
        "attention_mask": false,
        "guidance_scale": 1.0,
        "gradient_checkpointing": true,
        "offload_fraction": 0.0,
        "activation_offloading": false
    },
    "text_encoder_4_layer_skip": 0,
    "vae": {
        "__version": 0,
        "model_name": "",
        "include": true,
        "train": true,
        "stop_training_after": null,
        "stop_training_after_unit": "NEVER",
        "learning_rate": null,
        "weight_dtype": "FLOAT_32",
        "dropout_probability": 0.0,
        "train_embedding": true,
        "attention_mask": false,
        "guidance_scale": 1.0,
        "gradient_checkpointing": true,
        "offload_fraction": 0.0,
        "activation_offloading": false
    },
    "effnet_encoder": {
        "__version": 0,
        "model_name": "",
        "include": true,
        "train": true,
        "stop_training_after": null,
        "stop_training_after_unit": "NEVER",
        "learning_rate": null,
        "weight_dtype": "FLOAT_32",
        "dropout_probability": 0.0,
        "train_embedding": true,
        "attention_mask": false,
        "guidance_scale": 1.0,
        "gradient_checkpointing": true,
        "offload_fraction": 0.0,
        "activation_offloading": false
    },
    "decoder": {
        "__version": 0,
        "model_name": "",
        "include": true,
        "train": true,
        "stop_training_after": null,
        "stop_training_after_unit": "NEVER",
        "learning_rate": null,
        "weight_dtype": "FLOAT_32",
        "dropout_probability": 0.0,
        "train_embedding": true,
        "attention_mask": false,
        "guidance_scale": 1.0,
        "gradient_checkpointing": true,
        "offload_fraction": 0.0,
        "activation_offloading": false
    },
    "decoder_text_encoder": {
        "__version": 0,
        "model_name": "",
        "include": true,
        "train": true,
        "stop_training_after": null,
        "stop_training_after_unit": "NEVER",
        "learning_rate": null,
        "weight_dtype": "FLOAT_32",
        "dropout_probability": 0.0,
        "train_embedding": true,
        "attention_mask": false,
        "guidance_scale": 1.0,
        "gradient_checkpointing": true,
        "offload_fraction": 0.0,
        "activation_offloading": false
    },
    "decoder_vqgan": {
        "__version": 0,
        "model_name": "",
        "include": true,
        "train": true,
        "stop_training_after": null,
        "stop_training_after_unit": "NEVER",
        "learning_rate": null,
        "weight_dtype": "FLOAT_32",
        "dropout_probability": 0.0,
        "train_embedding": true,
        "attention_mask": false,
        "guidance_scale": 1.0,
        "gradient_checkpointing": true,
        "offload_fraction": 0.0,
        "activation_offloading": false
    },
    "masked_training": false,
    "unmasked_probability": 0.1,
    "unmasked_weight": 0.1,
    "normalize_masked_area_loss": false,
    "masked_prior_preservation_weight": 0.0,
    "custom_conditioning_image": false,
    "layer_filter": "attentions",
    "layer_filter_preset": "attn-mlp",
    "layer_filter_regex": false,
    "embedding_learning_rate": null,
    "preserve_embedding_norm": false,
    "embedding": {
        "__version": 0,
        "uuid": "753a3416-4ffe-4420-b1b1-18b6d432b608",
        "model_name": "",
        "placeholder": "<embedding>",
        "train": true,
        "stop_training_after": null,
        "stop_training_after_unit": "NEVER",
        "token_count": 1,
        "initial_embedding_text": "*",
        "is_output_embedding": false
    },
    "additional_embeddings": [],
    "embedding_weight_dtype": "FLOAT_32",
    "cloud": {
        "__version": 0,
        "enabled": false,
        "type": "RUNPOD",
        "file_sync": "NATIVE_SCP",
        "create": true,
        "name": "OneTrainer",
        "tensorboard_tunnel": true,
        "sub_type": "",
        "gpu_type": "",
        "volume_size": 100,
        "min_download": 0,
        "remote_dir": "/workspace",
        "huggingface_cache_dir": "/workspace/huggingface_cache",
        "onetrainer_dir": "/workspace/OneTrainer",
        "install_cmd": "git clone https://github.com/Nerogar/OneTrainer",
        "install_onetrainer": true,
        "update_onetrainer": true,
        "detach_trainer": false,
        "run_id": "job1",
        "download_samples": true,
        "download_output_model": true,
        "download_saves": true,
        "download_backups": false,
        "download_tensorboard": false,
        "delete_workspace": false,
        "on_finish": "NONE",
        "on_error": "NONE",
        "on_detached_finish": "NONE",
        "on_detached_error": "NONE"
    },
    "peft_type": "LORA",
    "lora_model_name": "",
    "lora_rank": 32,
    "lora_alpha": 16.0,
    "lora_decompose": false,
    "lora_decompose_norm_epsilon": true,
    "lora_decompose_output_axis": false,
    "lora_weight_dtype": "FLOAT_32",
    "bundle_additional_embeddings": true,
    "oft_block_size": 32,
    "oft_block_share": false,
    "oft_scaled": false,
    "lokr_dim": 16,
    "lokr_decompose_both": false,
    "lokr_decompose_factor": -1,
    "lokr_use_tucker": false,
    "lokr_weight_decompose": false,
    "lokr_dora_on_output": true,
    "lokr_full_matrix": false,
    "lokr_vec_trick": true,
    "optimizer": {
        "__version": 0,
        "optimizer": "ADAMW",
        "adam_w_mode": false,
        "alpha": null,
        "amsgrad": false,
        "beta1": 0.9,
        "beta2": 0.999,
        "beta3": null,
        "bias_correction": false,
        "block_wise": false,
        "capturable": false,
        "centered": false,
        "clip_threshold": null,
        "d0": null,
        "d_coef": null,
        "dampening": null,
        "decay_rate": null,
        "decouple": false,
        "differentiable": false,
        "eps": 1e-08,
        "eps2": null,
        "foreach": false,
        "fsdp_in_use": false,
        "fused": true,
        "fused_back_pass": false,
        "growth_rate": null,
        "initial_accumulator_value": null,
        "initial_accumulator": null,
        "is_paged": false,
        "log_every": null,
        "lr_decay": null,
        "max_unorm": null,
        "maximize": false,
        "min_8bit_size": null,
        "quant_block_size": null,
        "momentum": null,
        "nesterov": false,
        "no_prox": false,
        "optim_bits": null,
        "percentile_clipping": null,
        "r": null,
        "relative_step": false,
        "safeguard_warmup": false,
        "scale_parameter": false,
        "stochastic_rounding": false,
        "use_bias_correction": false,
        "use_triton": false,
        "warmup_init": false,
        "weight_decay": 0.01,
        "weight_lr_power": null,
        "decoupled_decay": false,
        "fixed_decay": false,
        "rectify": false,
        "degenerated_to_sgd": false,
        "k": null,
        "xi": null,
        "n_sma_threshold": null,
        "ams_bound": false,
        "adanorm": false,
        "adam_debias": false,
        "slice_p": null,
        "cautious": false,
        "weight_decay_by_lr": true,
        "prodigy_steps": null,
        "use_speed": false,
        "split_groups": true,
        "split_groups_mean": true,
        "factored": true,
        "factored_fp32": true,
        "use_stableadamw": true,
        "use_cautious": false,
        "use_grams": false,
        "use_adopt": false,
        "d_limiter": true,
        "use_schedulefree": true,
        "use_orthograd": false,
        "nnmf_factor": false,
        "orthogonal_gradient": false,
        "use_atan2": false,
        "use_AdEMAMix": false,
        "beta3_ema": null,
        "alpha_grad": null,
        "beta1_warmup": null,
        "min_beta1": null,
        "Simplified_AdEMAMix": false,
        "kourkoutas_beta": false,
        "schedulefree_c": null,
        "ns_steps": null,
        "MuonWithAuxAdam": false,
        "muon_hidden_layers": null,
        "muon_adam_regex": false,
        "muon_adam_lr": null,
        "muon_te1_adam_lr": null,
        "muon_te2_adam_lr": null,
        "muon_adam_config": {},
        "rms_rescaling": true,
        "normuon_variant": false,
        "beta2_normuon": null,
        "low_rank_ortho": false,
        "ortho_rank": null,
        "accelerated_ns": false,
        "cautious_wd": false,
        "approx_mars": false,
        "auto_kappa_p": false,
        "compile": false
    },
    "optimizer_defaults": {},
    "sample_definition_file_name": "training_samples/samples.json",
    "samples": [
        {
            "__version": 0,
            "enabled": true,
            "prompt": "vkstckrs, woman with blue eyes and black hair",
            "negative_prompt": "",
            "height": 512,
            "width": 512,
            "frames": 1,
            "length": 10.0,
            "seed": 42,
            "random_seed": false,
            "diffusion_steps": 30,
            "cfg_scale": 7.5,
            "noise_scheduler": "EULER_A",
            "text_encoder_1_layer_skip": 0,
            "text_encoder_1_sequence_length": null,
            "text_encoder_2_layer_skip": 0,
            "text_encoder_2_sequence_length": null,
            "text_encoder_3_layer_skip": 0,
            "text_encoder_4_layer_skip": 0,
            "transformer_attention_mask": false,
            "force_last_timestep": false,
            "sample_inpainting": false,
            "base_image_path": "",
            "mask_image_path": ""
        }
    ],
    "sample_after": 100,
    "sample_after_unit": "STEP",
    "sample_skip_first": 0,
    "sample_image_format": "JPG",
    "sample_video_format": "MP4",
    "sample_audio_format": "MP3",
    "samples_to_tensorboard": true,
    "non_ema_sampling": true,
    "backup_after": 10,
    "backup_after_unit": "MINUTE",
    "rolling_backup": false,
    "rolling_backup_count": 3,
    "backup_before_save": true,
    "save_every": 100,
    "save_every_unit": "STEP",
    "save_skip_first": 0,
    "save_filename_prefix": ""
}
После старта обучения скачивание моделей может занять время, шкала обновляется не сразу
После старта обучения скачивание моделей может занять время, шкала обновляется не сразу

Обучение

Обучение шло ~3 итерации в секунду первые 12 эпох, дальше упало до 1,5 секунд на итерацию. Потом обучение то ускорялось до нескольких итераций в секунду, то замедлялось до пары секунд на итерацию.

Скорость менялась непосредственно в процессе. Для такого эксперимента это не представляло проблемы: модель и весь датасет достаточно лёгкие, поэтому полное обучение всё равно занимало приемлемое время.

Программа занимала ~20 гигабайт оперативной памяти и ~3 гигабайта видеопамяти
Программа занимала ~20 гигабайт оперативной памяти и ~3 гигабайта видеопамяти

На 400 шагах сэмпл дал первые признаки обучения и смещения к целевому стилю. После 1700 шагов стилевая разница между сэмплами стала неочевидной. На 2500 было принято остановить обучение.

Решение об остановке принималось не только по значению loss. Для генеративных моделей уменьшение loss само по себе не означает, что более поздний чекпоинт будет визуально лучше. Гораздо важнее отслеживать промежуточные изображения и смотреть, продолжает ли модель осваивать стиль или уже начинает чрезмерно закреплять признаки небольшого датасета.

В данном случае после определённого момента качественные изменения стали минимальными. Дальнейшее обучение могло увеличить вероятность переобучения, но не давало очевидного визуального выигрыша.

Первые результаты ещё похожи на обычные генерации базовой SD 1.5, затем влияние обучаемой LoRA становится заметнее
Первые результаты ещё похожи на обычные генерации базовой SD 1.5, затем влияние обучаемой LoRA становится заметнее

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

Подготовка к инференсу

После обучения нужно было подготовить workflow, в котором можно быстро включать и выключать LoRA, менять её силу и сравнивать результат с базовой моделью.

Базовый SD1.5 workflow от ComfyUI был доработан: добавлен блок загрузки LoRA и ее CLIP (текстового энкодера, который теперь знает уникальный триггер и его значение), второй блок Ksampler для апскейлинга (hirez fix) и блоки «if else» для управления LoRA (вкл/выкл) и для управления апскейлингом. Блоки были сгруппированы по этапам процесса генерации.

Изнутри доработанный workflow может пугать своей сложностью неподготовленного пользователя
Изнутри доработанный workflow может пугать своей сложностью неподготовленного пользователя

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

Условные блоки нужны не только для удобства. Возможность отключить LoRA и повторить генерацию с тем же сидом позволяет наглядно увидеть именно её влияние, не смешивая его с обычной случайностью диффузионной модели.

Весь workflow был завернут в одну ноду, все важные параметры (промты, шаги, cfg, сила LoRA, переключатели условных блоков и так далее) были вынесены в эту ноду. Снаружи к ней были подключены удобные сторонние кастомные ноды для управления сидом генерации с возможностью быстро зафиксировать последний случайный сид для повторного прогона и для наглядного сравнения генераций до/после апскейлинга.

Такой user-friendly интерфейс выглядит гораздо понятнее и удобнее
Такой user‑friendly интерфейс выглядит гораздо понятнее и удобнее

Такой подход не меняет внутреннюю работу ComfyUI, но делает использование большого графа удобнее. Вместо перемещения между группами нод основные параметры собраны в одном месте. При этом сам workflow остаётся доступен для редактирования и дальнейшего расширения.

Выбор базовой модели для генерации

Чекпоинт, на котором проводилось обучение, для инференса не подходит — слишком сильные галлюцинации, много артефактов. На платформе CivitAI собраны десятки моделей, обученных на базе SD1.5. Некоторые имеют стилевые предпочтения, например, Mistoon Anime заточена под мультяшный стиль. Выбрал несколько самых популярных для теста.

Платформа существует со времен популяризации локального генеративного ИИ, модели на базе SD1.5 насчитывают миллионы скачиваний
Платформа существует со времен популяризации локального генеративного ИИ, модели на базе SD1.5 насчитывают миллионы скачиваний

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

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

По тестам с одинаковым описанием мне больше понравился результат модели Dream Shaper.

Модель была дообучена сообществом с уклоном в реализм и общий объём знаний объектов, животных и анатомии
Модель была дообучена сообществом с уклоном в реализм и общий объём знаний объектов, животных и анатомии
Все модели, за исключением базовой SD1.5 (справа), дали сопоставимо хорошие результаты
Все модели, за исключением базовой SD1.5 (справа), дали сопоставимо хорошие результаты

Для сравнения использовались одинаковые или максимально близкие параметры, один prompt и один seed. Это позволило оценить различия между моделями, а не случайно выбрать особенно удачную генерацию.

В качестве базовой можно использовать любую модель в зависимости от ваших предпочтений и целей.

Dream Shaper оказалась наиболее подходящей для моих тестов, но это не универсальный выбор. Для более выраженного аниме‑стиля, плоской графики или другого типа персонажей другая модель может дать лучший результат.

Сэмплер и параметры генерации

Перебирать все существующие сэмплеры большого смысла нет. Достаточно знать, что для 2D иллюстрации лучше всего подходят Euler и Euler ancestral. Scheduler Karas также рекомендуется. Не менее популярный DPM++ 2M склонен ломать сложную анатомию и композицию, потому что быстро фиксирует их на ранних шагах, поэтому отдаем предпочтение именно Euler.

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

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

При обучении мы не использовали описание стиля «vector illustration of a cartoon [subject]» чтобы модель связала стиль с нашим уникальным триггером «vkstckrs». Теперь же, во время генерации мы можем использовать и триггер, и описание для усиления эффекта.

Например, один только триггер позволяет проверить, что именно выучила LoRA. Дополнительное описание vector illustration of a cartoon направляет одновременно и базовую модель, и LoRA в нужную сторону, поэтому итоговый эффект становится выраженнее.

Настройки силы LoRA и CFG scale подбираются под конкретный шаблон промпта, базовую модель и общую задачу. В моем случае я пришел к силе LoRA 1.0 и CFG 10.0.

Слишком низкая сила LoRA оставляла результат близким к обычной генерации Dream Shaper. При чрезмерном увеличении силы изображение начинало терять анатомию и становилось грубее. Значение 1.0 оказалось подходящим для выбранного чекпоинта и шаблона описания.

CFG 10.0 также нельзя считать универсальной рекомендацией. При изменении модели, сэмплера, числа шагов или структуры prompt оптимальное значение может отличаться.

Тестирование стилевой LoRA

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

В каждой паре отличается только состояние LoRA. Это позволяет увидеть, какие признаки появились именно после её подключения.

На животных влияние LoRA проявляется особенно хорошо: изображения получают более выраженный контур, упрощённые формы и мультяшность, подходящую для отдельного стикера
На животных влияние LoRA проявляется особенно хорошо: изображения получают более выраженный контур, упрощённые формы и мультяшность, подходящую для отдельного стикера
При генерации людей общий визуальный язык становится значительно более согласованным: персонажи выглядят как элементы одного стикерпака, а не как независимые иллюстрации
При генерации людей общий визуальный язык становится значительно более согласованным: персонажи выглядят как элементы одного стикерпака, а не как независимые иллюстрации
С едой и понятными объектами LoRA также работает, хотя иногда пытается их антропоморфизировать, для стикеров это не обязательно является недостатком, поскольку глаза, эмоции и конечности часто являются частью подобного жанра
С едой и понятными объектами LoRA также работает, хотя иногда пытается их антропоморфизировать, для стикеров это не обязательно является недостатком, поскольку глаза, эмоции и конечности часто являются частью подобного жанра

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

Совмещение двух LoRA

Позже в workflow была добавлена поддержка еще одной LoRA. Теперь можно подключать сторонние LoRA персонажей из сообщества CivitAI. Это позволило получать консистентный образ в консистентном стиле. Для примера возьмем этого персонажа:

Джейн Портер, кадр из «Тарзана» 1999 студии Walt Disney Pictures
Джейн Портер, кадр из «Тарзана» 1999 студии Walt Disney Pictures

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

Скачаем подходящую LoRA этого персонажа для SD1.5:

Несмотря на сильное визуальное отличие образа от оригинала на конкретных примерах в карточке модели, внутри себя она содержит достаточно знаний обобщенного образа персонажа для более точного воспроизведения в нашем workflow
Несмотря на сильное визуальное отличие образа от оригинала на конкретных примерах в карточке модели, внутри себя она содержит достаточно знаний обобщенного образа персонажа для более точного воспроизведения в нашем workflow

При подключении сторонних LoRA нужно учитывать, на какой базовой архитектуре и на каких чекпоинтах они обучались. В данном случае обе LoRA совместимы с SD 1.5, поэтому их можно использовать в одном workflow.

Workflow позволяет тонко настроить вторую LoRA отдельно. В итоге получаем возможность создавать заготовки для стикерпака, на которые остаётся добавить текст:

Четыре генерации с разными эмоциями: счастье, грусть, испуг и злость
Четыре генерации с разными эмоциями: счастье, грусть, испуг и злость

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

Можно взять любую готовую LoRA персонажа или обучить свою, интегрировать в workflow и создавать любые стикерпаки.

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

А что с frontier моделями крупных студий?

Протестируем Nano banana 2 (Gemini) и ChatGPT Images 2.0 в тех же задачах копирования стиля и адаптации персонажа.

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

Попытка Gemini повторить нужный стиль на основе четырех примеров
Попытка Gemini повторить нужный стиль на основе четырех примеров
Попытка ChatGPT в аналогичных условиях
Попытка ChatGPT в аналогичных условиях

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

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

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

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

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

При этом сравнение нельзя сводить только к визуальному качеству одного изображения. У локальной модели другой сценарий использования: её можно тонко настроить, встроить в собственный workflow, объединить с другими LoRA и запускать многократно с полностью контролируемыми параметрами.

Где это полезно? Каков итог?

Речь не о разовой генерации своей внешности в стиле стикера ради интереса или развлечения. Такая тонко настроенная легкая локальная модель в первую очередь предназначается для автоматизации производства контента с сохранением консистентного стиля. Исключается необходимость в использовании «сервисов из трех букв» для получения доступа к зарубежным нейросетям, в открытии виртуальных карт для оплаты подписок и API, в использовании интернета в целом, в аренде мощных серверов и в соблюдении лимитов или ограничений вариативности генераций.

После однократной подготовки датасета и обучения LoRA можно быстро создавать большое количество изображений на локальном компьютере. Стиль не нужно заново описывать и уточнять для каждой генерации: он уже закреплён в весах и активируется уникальным триггером.

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

  • создавать стикеры одного персонажа с разными эмоциями;

  • производить контент для сообщества;

  • поддерживать визуальный стиль маскота;

  • готовить реакции для чат‑бота;

  • генерировать однотипные иллюстрации для интерфейса.

При этом SD 1.5 остаётся старой моделью со своими ограничениями. Она может ошибаться в сложных позах, жестах, взаимодействиях объектов и анатомии. LoRA не устраняет эти недостатки, поскольку отвечает прежде всего за обученные признаки стиля. Для сложных сцен всё равно потребуются отбор, повторные генерации, inpainting или ручная доработка.

Главный результат эксперимента заключается в том, что небольшой датасет из 30 тщательно отобранных изображений оказался достаточным для обучения узнаваемой стилевой LoRA. За счёт разнообразия персонажей модель не закрепилась на одном герое, а за счёт разметки содержимого связала общую визуальную подачу с токеном «vkstckrs».

Дополнительное подключение персонажной LoRA показало, что SD 1.5 может одновременно удерживать и визуальную идентичность героя, и заданный стиль. В результате получается управляемый локальный инструмент, пригодный не только для единичных экспериментов, но и для автоматизации серийной генерации.

Всем добра!

Комментарии (0)