Генерация красивых единичных артов давно перестала быть проблемой. Но как только возникает прикладная задача — например, сделать серию консистентных иллюстраций или заготовок для стикерпака в едином стиле — обычный промпт‑инжиниринг начинает буксовать. Закрытые модели дают высокое качество, но забирают контроль и навязывают свое видение.
Поэтому я решил собрать собственный контролируемый пайплайн. Базой для эксперимента была выбрана 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 osfrom PIL import Imagedef 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.
Скрипт (Python): Автоматический captioning через BLIP
Скрипт локально загружает модель Salesforce/blip-image-captioning-large с Hugging Face и создает .txt файл для каждой картинки. Мы сразу закладываем возможность передать наш триггер vkstckrs через аргументы командной строки.
#!/usr/bin/env python3"""Автоматическая генерация caption-файлов для датасета LoRA SD 1.5."""from __future__ import annotationsimport argparseimport sysfrom pathlib import Pathfrom typing import Iterableimport torchfrom PIL import Image, ImageOps, UnidentifiedImageErrorfrom tqdm import tqdmfrom transformers import BlipForConditionalGeneration, BlipProcessorIMAGE_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 resultdef 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 0if __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 особенно заметны на стилизованных и сильно упрощённых изображениях. Модель может неверно определить животное, перепутать предметы или додумать отсутствующие элементы. Поэтому ручная проверка остаётся обязательной даже при автоматизации captioning.
Gemini написал программу, которая удалила подстроку «vector illustration of a cartoon » из всех файлов разметки. После удаления подстроки я ещё раз просмотрел все текстовые файлы и проверил, что предложения остались грамматически понятными, а описание субъекта не потерялось.
Скрипт (Python): Удаление подстроки из.txt файлов
Утилитарный скрипт, который проходит по всем файлам датасета и вычищает повторяющийся стиль, оставляя только описание субъекта.
import osfrom pathlib import Pathdef 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 секунд на итерацию. Потом обучение то ускорялось до нескольких итераций в секунду, то замедлялось до пары секунд на итерацию.
Скорость менялась непосредственно в процессе. Для такого эксперимента это не представляло проблемы: модель и весь датасет достаточно лёгкие, поэтому полное обучение всё равно занимало приемлемое время.
На 400 шагах сэмпл дал первые признаки обучения и смещения к целевому стилю. После 1700 шагов стилевая разница между сэмплами стала неочевидной. На 2500 было принято остановить обучение.
Решение об остановке принималось не только по значению loss. Для генеративных моделей уменьшение loss само по себе не означает, что более поздний чекпоинт будет визуально лучше. Гораздо важнее отслеживать промежуточные изображения и смотреть, продолжает ли модель осваивать стиль или уже начинает чрезмерно закреплять признаки небольшого датасета.
В данном случае после определённого момента качественные изменения стали минимальными. Дальнейшее обучение могло увеличить вероятность переобучения, но не давало очевидного визуального выигрыша.
На графике и сэмплах можно проследить, как постепенно усиливаются основные признаки: упрощённая форма, мультяшность, выраженный контур и более близкая к стикерам композиция.
Подготовка к инференсу
После обучения нужно было подготовить workflow, в котором можно быстро включать и выключать LoRA, менять её силу и сравнивать результат с базовой моделью.
Базовый SD1.5 workflow от ComfyUI был доработан: добавлен блок загрузки LoRA и ее CLIP (текстового энкодера, который теперь знает уникальный триггер и его значение), второй блок Ksampler для апскейлинга (hirez fix) и блоки «if else» для управления LoRA (вкл/выкл) и для управления апскейлингом. Блоки были сгруппированы по этапам процесса генерации.
Первый проход отвечает за основную композицию и содержание изображения. Второй KSampler работает уже на увеличенной версии и позволяет добавить детали, сохранив общую структуру исходной генерации.
Условные блоки нужны не только для удобства. Возможность отключить LoRA и повторить генерацию с тем же сидом позволяет наглядно увидеть именно её влияние, не смешивая его с обычной случайностью диффузионной модели.
Весь workflow был завернут в одну ноду, все важные параметры (промты, шаги, cfg, сила LoRA, переключатели условных блоков и так далее) были вынесены в эту ноду. Снаружи к ней были подключены удобные сторонние кастомные ноды для управления сидом генерации с возможностью быстро зафиксировать последний случайный сид для повторного прогона и для наглядного сравнения генераций до/после апскейлинга.
Такой подход не меняет внутреннюю работу ComfyUI, но делает использование большого графа удобнее. Вместо перемещения между группами нод основные параметры собраны в одном месте. При этом сам workflow остаётся доступен для редактирования и дальнейшего расширения.
Выбор базовой модели для генерации
Чекпоинт, на котором проводилось обучение, для инференса не подходит — слишком сильные галлюцинации, много артефактов. На платформе CivitAI собраны десятки моделей, обученных на базе SD1.5. Некоторые имеют стилевые предпочтения, например, Mistoon Anime заточена под мультяшный стиль. Выбрал несколько самых популярных для теста.
Обучение на базовом чекпоинте полезно тем, что LoRA не привязывается к слишком специфическим особенностям одной производной модели. Но использовать этот же исходный чекпоинт для финальных генераций необязательно.
Производные модели могут лучше справляться с композицией, цветом, мультяшными персонажами и общей визуальной привлекательностью. Важно лишь проверить, насколько корректно обученная LoRA переносится на выбранный чекпоинт.
По тестам с одинаковым описанием мне больше понравился результат модели Dream Shaper.
Для сравнения использовались одинаковые или максимально близкие параметры, один 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. Это позволяет увидеть, какие признаки появились именно после её подключения.
Результаты показывают, что даже 30 разнообразных изображений оказалось достаточно для передачи основных признаков стиля. Модель не воспроизводит конкретный исходный стикер, а применяет усвоенные закономерности к новым субъектам.
Совмещение двух LoRA
Позже в workflow была добавлена поддержка еще одной LoRA. Теперь можно подключать сторонние LoRA персонажей из сообщества CivitAI. Это позволило получать консистентный образ в консистентном стиле. Для примера возьмем этого персонажа:
Стилевая LoRA отвечает за визуальную подачу, а вторая LoRA — за признаки конкретного персонажа. Их силы можно регулировать независимо, добиваясь нужного баланса между узнаваемостью и стилизацией.
Скачаем подходящую LoRA этого персонажа для SD1.5:
При подключении сторонних LoRA нужно учитывать, на какой базовой архитектуре и на каких чекпоинтах они обучались. В данном случае обе LoRA совместимы с SD 1.5, поэтому их можно использовать в одном workflow.
Workflow позволяет тонко настроить вторую LoRA отдельно. В итоге получаем возможность создавать заготовки для стикерпака, на которые остаётся добавить текст:
Здесь важно, что разные эмоции и ситуации сохраняют как основные черты персонажа, так и единый способ отрисовки. Отдельные генерации всё равно могут потребовать отбора, исправления или повторного запуска, но общий визуальный образ остаётся достаточно консистентным.
Можно взять любую готовую LoRA персонажа или обучить свою, интегрировать в workflow и создавать любые стикерпаки.
Этот же принцип применим не только к существующим героям. Можно обучить собственную LoRA на оригинальном персонаже, маскоте, живом человеке или другом визуальном образе и использовать стилевую модель для производства целой серии.
А что с frontier моделями крупных студий?
Протестируем Nano banana 2 (Gemini) и ChatGPT Images 2.0 в тех же задачах копирования стиля и адаптации персонажа.
Цель сравнения — не устроить полноценный бенчмарк. У закрытых моделей неизвестная архитектура, другое количество параметров и принципиально иной интерфейс. Но интересно проверить, насколько хорошо популярные для таких задач инструменты выполняют ту же практическую задачу без отдельного обучения.
На мой взгляд стиль получается не на 100%, а общее качество выглядит слишком высоким для стикеров, нарисованных художником на условном графическом планшете. Но в целом обе модели хорошо понимают стиль мультяшной векторной графики.
Закрытые модели создают более чистые, детализированные и технически качественные изображения. Однако это же качество может мешать, если целью является имитация более простой ручной графики. Результаты начинают выглядеть скорее как профессиональная векторная иллюстрация, чем как обычный пользовательский стикерпак.
Задачу использования стороннего персонажа они тоже решают, но, если речь идет о персонажах, защищенных авторским правом, у моделей срабатывает внутренний фильтр. В случае с Джейн Портер обе модели смогли создать требуемые стикеры, причем ChatGPT использовал две попытки и уточнил стиль второй генерацией.
Обе модели дали хороший результат, пригодный для использования. Отмечу, что я не ставил целью доказать превосходство локального ИИ над передовыми моделями. Но как показали результаты, локальная модель до 1 млрд параметров в отдельных задачах способна соревноваться с закрытыми моделями на сотни миллиардов параметров.
При этом сравнение нельзя сводить только к визуальному качеству одного изображения. У локальной модели другой сценарий использования: её можно тонко настроить, встроить в собственный workflow, объединить с другими LoRA и запускать многократно с полностью контролируемыми параметрами.
Где это полезно? Каков итог?
Речь не о разовой генерации своей внешности в стиле стикера ради интереса или развлечения. Такая тонко настроенная легкая локальная модель в первую очередь предназначается для автоматизации производства контента с сохранением консистентного стиля. Исключается необходимость в использовании «сервисов из трех букв» для получения доступа к зарубежным нейросетям, в открытии виртуальных карт для оплаты подписок и API, в использовании интернета в целом, в аренде мощных серверов и в соблюдении лимитов или ограничений вариативности генераций.
После однократной подготовки датасета и обучения LoRA можно быстро создавать большое количество изображений на локальном компьютере. Стиль не нужно заново описывать и уточнять для каждой генерации: он уже закреплён в весах и активируется уникальным триггером.
Практический смысл особенно заметен в задачах, где нужно выпускать серии изображений:
-
создавать стикеры одного персонажа с разными эмоциями;
-
производить контент для сообщества;
-
поддерживать визуальный стиль маскота;
-
готовить реакции для чат‑бота;
-
генерировать однотипные иллюстрации для интерфейса.
При этом SD 1.5 остаётся старой моделью со своими ограничениями. Она может ошибаться в сложных позах, жестах, взаимодействиях объектов и анатомии. LoRA не устраняет эти недостатки, поскольку отвечает прежде всего за обученные признаки стиля. Для сложных сцен всё равно потребуются отбор, повторные генерации, inpainting или ручная доработка.
Главный результат эксперимента заключается в том, что небольшой датасет из 30 тщательно отобранных изображений оказался достаточным для обучения узнаваемой стилевой LoRA. За счёт разнообразия персонажей модель не закрепилась на одном герое, а за счёт разметки содержимого связала общую визуальную подачу с токеном «vkstckrs».
Дополнительное подключение персонажной LoRA показало, что SD 1.5 может одновременно удерживать и визуальную идентичность героя, и заданный стиль. В результате получается управляемый локальный инструмент, пригодный не только для единичных экспериментов, но и для автоматизации серийной генерации.
Всем добра!
ссылка на оригинал статьи https://habr.com/ru/articles/1065580/