Привет, друзья. Приперся я на этот печально известный форум с мыслью поделиться знаниями и узнать что-то новое, но как-то не сложилось, и мой аккаунт затерялся в глубинах интернета. Да ладно, пусть ЦРУ его и ломает, что там им интересного в моих постах? Немного отдохнул, и вот, решил зарегистрироваться заново, начать все с чистого листа. Статья была готова на 30%, вбил в поиск 'стеганография', и нашел тему: - DildoFagins говорил, что хочет пробудить в нас интерес к стеганографии. Но глядя на наш форум, не нашел больше интересных статей по этой теме. Ну что ж, придется писать еще одну, если, конечно, наша публика заинтересована. Это будет неофициальное продолжение статьи DildoFagins. Так что советую всем почитать этот шедевр, там все классно расписано про LSB. Что касается моей статьи, это мой дебют здесь, и, если говорить шире, первая полезная инфа для форума от меня. XSS дал мне многое. У меня есть опыт написания научных статей, но блог – это что-то другое. На практике – гораздо веселее, честно говоря.
Проблема с JPEG
"С другой стороны, в формате JPEG информация о пикселях сжимается с потерями, поэтому хранить и восстанавливать полезные данные таким же образом у нас не получится. Возможно, мы потом рассмотрим, как реализовать стеганографию в формате JPEG, если вам будет это интересно..." - DildoFagins
Для начала нам нужно понять, как работает JPEG: Так что, в отличие от нашего PNG, мы не можем напрямую изменять младший значащий бит изначального изображения, но нам нужно действовать до уровня кодирования Хаффмана и после квантования, потому что все дальше идет без потерь. Но, дорогие товарищи, лично я не нашел таких библиотек на python, которые бы позволяли работать с промежуточными этапами процесса кодирования JPEG. Насколько я понимаю, большинство кодировщиков/декодировщиков JPEG не позволят нам установить контрольные точки в процессе кодирования. Если кто-то знает что-то по этому поводу, обсудите в комментариях, пожалуйста. Конечно, мы можем выбрать открытую библиотеку с github и аккуратно вызвать внутренние функции, но это будет целая заморочка для нас. Если кто-то знает такие библиотеки, дайте знать, или может быть, мы позже напишем ее сами. Всё-таки, чтобы понять, куда умно засунуть наши секретные данные, мне нужно освоить весь процесс. Но это позже, там все скучно.
Нейросети
Давайте будем считать, что у вас уже есть базовое понимание нейронов, весов, отклонений, MLP, свертки и основ pytorch. Всё это можно найти в Сети, так что мы не будем "тащить лямку" в этой статье. Это и не совсем по теме нашего форума. Если ошибаюсь, admin, поправь меня. Если это подходит для нашего форума, рассмотрю этот вопрос поподробнее. Мне не интересно то, что уже валяется на medium.com, stackoverflow, github. А, кстати, добавьте поддержку markdown вместо устаревшего BBCode. Пора бы уже отправить этого старичка на покой (шучу, шучу! Мы любим и уважаем BBCode, наше детство с начала 2000-х, когда молодой Рунет только ставил свои первые шаги. Но мои молодые коллеги смеяться, если я не буду использовать модный markdown). Добавьте возможность редактирования статей в markdown, это же так удобно!Итак, перейдем к делу. Эта статья посвящена принципу стеганографии изображений, чтобы создать аналогичное изображение с нашим закодированным изображением. Самый простой метод, который приходит в голову, - это изменение LSB. Преимущество скрытия данных изображения в том, что допускается потеря качества. В целом, наш метод будет потерянным, так что наша конечная цель после создания PoC (то есть каким-то образом создать сеть, чтобы скрыть ее в основном изображении) - это улучшить качество как основного (в приоритете), так и скрытого изображения. Но читая эту статью до конца, вы поймете, почему эти цели неразделимы.
Мы не будем акцентировать внимание на шифровании данных. В первой статье вы, возможно, заметили, что DildoFagins говорил, что шифрование не влияет на алгоритм LSB. Вы можете подумать: "Ну, добавим простой слой шифрования и дешифрования AES-256, какая разница?" Это верно для LSB, но для нейросетей это намного сложнее. Обсудим, возможно ли это в принципе, когда это возможно, потому что если это возможно, то мы как бы подрываем стеганографию нейросети (это ваша подсказка, если вы поняли, о чем я, оставьте свой комментарий после прочтения статьи). Возможно, в другой статье расскажем, как вставить криптографию в нашу стеганографическую нейросеть, если товарищи заинтересованы (это вообще возможно? если да, то насколько сложно?). Теперь можем глупо разделить процесс на две части: генерацию самого основного изображения с шумом + кодирование наших данных.
Создание обложки со шумами
Сначала попробуем создать что-то похожее на изображение, при этом никакой особенный шум мы сами добавлять не будем, но я покажу, как шум добавляется автоматом. Для этого понадобится база изображений. И вот сюрпризик: нам не нужны метки, описания, потому что без понятия нам, что там на картинке вообще. Используй что угодно для обучения: свои личные фотки, порнографию, что душе угодно. Главное для качественного результата – чтобы база была большой и разнообразной с массой разных элементов: цвета, текстуры и так далее. Самой крутой базой, которую можно найти, будет ImageNet, в котором миллионы разных качественных фоток, более 100 ГБ. Но так как наши ресурсы ограничены и большинство из нас на этом форуме — фанаты таких вот стареньких ноутов, как thinkpads, и старых коней вроде меня. Мы просто возьмем Tiny ImageNet с подмножеством 60 000 изображений (64x64) для этой статьи. Это простой наборчик для экспериментов с глубоким обучением, по моему мнению, идеально для парней, у которых не так много вычислительных ресурсов, но которые хотят захватить мир с картой в кармане.
Начнем с создания класса, который будет управлять TinyImageNet на жестком диске. Напишем метод _download_and_extract, который выполнит простой HTTP GET запрос по ссылке, будем скачивать ответ блоками по 1024 байта, чтобы справиться с большим объемом загрузки. Простенький индикатор прогресса tqdm будет использоваться на протяжении всей статьи, так что если ты не в курсе, что такое tqdm, загляни в документацию. Все просто, это, по сути, обертка вокруг итераций в питоне. И, наконец, распакуем это все с помощью простого инструмента unzip (для Linux).
Python:
from pathlib import Path
from tqdm import tqdm
import requests
import os
DATA_DIR = Path("./data/tiny-imagenet-200")
class TinyImageNet:
def __init__(self, data_dir: Path):
self.data_dir = data_dir
self.data_dir.mkdir(parents=True, exist_ok=True)
self.TINY_IMGNET_URL = "http://cs231n.stanford.edu/tiny-imagenet-200.zip"
def download(self) -> None:
archive = self.data_dir / "tiny-imagenet-200.zip"
if not (self.data_dir / "tiny-imagenet-200").exists():
self._download_and_extract(archive)
def _download_and_extract(self, archive: Path) -> None:
with archive.open('wb') as file, requests.get(self.TINY_IMGNET_URL, stream=True) as response:
total = int(response.headers.get('content-length', 0))
for data in tqdm(response.iter_content(1024), total=total // 1024, unit='KB', desc="downloading"):
file.write(data)
print("extracting...")
os.system(f"unzip -q {archive} -d {self.data_dir}")
archive.unlink()
Давайте с этого и начнём. Сначала загрузим наши скачанные датасеты и разделим их на три части: обучающую, оценочную и тестовую. Как правило, если откроешь какие-то такие отстойные видосы на ютубе, то найдешь всего два набора: обучающий и тестовый. Ну это же нонсенс! Люди говорят, что глубокое обучение - это просто: написал модель, обучил её на каких-то данных и вуаля, магия в деле. На таких детских замечаниях я бы ответил: "Да ладно, попробуй сам-то". Тестовый набор данных мы вообще не будем трогать, и не будем обучать на оценочном и тестовом наборах. Тестовый набор - это только после того, как всё завершено, используем его чтобы проверить, как моделька себя покажет. Оценочный набор помогает нам отловить "косяки" (тут не про насекомых) нашей модели, узнать, учится ли она или просто запоминает обучающие данные. Разделение нашего всего набора TinyImageNet будет таким: (80%, 10%, 10%). Но прежде чем делить, мы добавим немного магии с помощью трансформаций, чтобы ещё улучшить наши данные. Все трансформации PyTorch можно найти в документации. Выберем самое важное для нашей задачи по стеганографии (пока мы просто создаем исходное изображение), пытаемся воссоздать исходное изображение из ряда свёрток. Но так как внедрение данных в исходные изображения влияет на яркость, насыщенность, оттенок и так далее, нам нужно выбрать трансформации, которые обеспечивают наибольшее разнообразие в конечном датасете. Вот что я выбрал:
Прочитай дальше, и узнаешь мой секретный рецепт выбора таких странных значений. Весь фокус в Стохастической Оптимизации. Смешно звучит, да? Но на самом деле это всего лишь метод научного тыка. Я постоянно корректировал значения, смотрел на изображения и выбирал лучшее.
ColorJitter: создаёт небольшие изменения в яркости, контрасте, насыщенности и оттенке. Очень важно для нашей модели стеганографии, потому что эти изменения могут нарушить внедрённый секрет. Исследования показали, что изменения в яркости влияют на человеческий глаз сильнее, чем цвет.
RandomGrayScale: как и говорится в названии, переводит RGB в оттенки серого. С стеганографией в оттенках серого всё не так просто, так что дал ему всего 5% шанса.
RandomHorizontalFlip & RandomVerticalFlip: если увидишь новичков, которые используют только эти трансформации в своих "ультрасовременных" моделях, можешь смело говорить, что это лохи. Изменение ориентации не портит изображение, но меняет отношения пикселей, что делает внедрение данных сложнее.
RandomRotation: поворот меняет положение пикселей, заставляя процесс внедрения и извлечения адаптироваться к изменяющимся пространственным отношениям. 12 градусов - это небольшой поворот без особых искажений.
RandomAdjustSharpness: изменяет остроту изображения. Обучение на разных уровнях резкости помогает нашей модели справляться с такими помехами. Выбрал коэффициент 1.3 - как раз, чтобы добавить остроту без потери качества.
RandomAutocontrast: немного черезчур для нашей маленькой модельки, но в сильной модели можно использовать для максимизации контраста изображения. Контрастные изменения могут нарушить процесс стеганографии, особенно если данные скрыты в изменениях интенсивности пикселей. Запомни эту строчку, потом к ней вернусь. Если забуду, пиши в комментах.
RandomSolarize: можем инвертировать пиксели выше определенного порога. Это нестандартное преобразование, которое может значительно изменить значения пикселей, особенно в светлых областях.
RandomEqualize: распределяет наиболее частые значения интенсивности. Выравнивание может сделать некоторые внедренные данные более видимыми. Модели надо учиться справляться с такими сценариями.
И, наконец, немного гауссовского шума: добавляем случайный шум к изображению. Шум может затруднить извлечение внедренных данных. Модель стеганографии должна быть устойчива к такому шуму. Я сделал интенсивность шума умеренной на 20%, чтобы не убить наш датасет.
Python:
import torch
from torchvision import datasets, transforms
from torch.utils.data import DataLoader, random_split
from typing import Tuple
def load_dataset(self) -> Tuple[torch.utils.data.Dataset, torch.utils.data.Dataset, torch.utils.data.Dataset]:
transform = transforms.Compose([
transforms.RandomApply([transforms.ColorJitter(brightness=0.15, contrast=0.15, saturation=0.15, hue=0.15)], p=0.3),
transforms.RandomGrayscale(p=0.05),
transforms.RandomHorizontalFlip(p=0.5),
transforms.RandomVerticalFlip(p=0.25),
# transforms.RandomRotation(12, fill=(0.5, 0.5, 0.5)),
transforms.RandomApply([transforms.RandomAdjustSharpness(sharpness_factor=1.3)], p=0.15),
transforms.RandomApply([transforms.RandomAutocontrast()], p=0.15),
transforms.RandomApply([transforms.RandomSolarize(threshold=0.5)], p=0.05),
transforms.RandomApply([transforms.RandomEqualize()], p=0.08),
transforms.ToTensor(),
transforms.RandomApply([transforms.Lambda(lambda x: x + 0.05 * torch.randn_like(x))], p=0.2),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])
full_dataset = datasets.ImageFolder(self.data_dir / "tiny-imagenet-200/train", transform=transform)
train_len = int(0.8 * len(full_dataset))
val_len = int(0.1 * len(full_dataset))
test_len = len(full_dataset) - train_len - val_len
return random_split(full_dataset, [train_len, val_len, test_len])
Теперь нам надо загрузить наши данные по-умному в загрузчики данных, которые сами будут бегать по нашему датасету пачками (в нашем коде размер пачки 64). Я использовал num_workers для количества рабочих потоков. А теперь для всех наших "олигархов" с графическими картами NVIDIA (с поддержкой CUDA) - можно кидать тензоры и операции из медленной оперативки прямиком на быструю память GPU. Само копирование данных стоит дорого, но когда все устроилось на GPU, тензоры будут рубиться параллельно на тысячах ядер CUDA. Одно из забавных фишек pytorch в том, что это можно сделать в одну строчку кода. Сначала мы проверим, доступна ли подходящая графическая карта с помощью torch.cuda.is_available(), а потом установим устройство на torch.device('cuda'), а если нет - 'cpu'. Когда работаем с CUDA, нам важно, чтобы наша линия загрузки данных была на высоте, так что используем pin_memory=True, чтобы ускорить перекидывание на GPU. Передача данных с оперативной памяти (RAM) на GPU будет быстрее, если она идет из закрепленной памяти.
torch.utils.data — PyTorch 2.10 documentation
pytorch.org
Pytorch. How does pin_memory work in Dataloader?
I want to understand how the pin_memory parameter in Dataloader works. According to the documentation: pin_memory (bool, optional) – If True, the data loader will copy tensors into CUDA pinned mem...
Теперь давай проверим, что все приготовилось как надо, включая и сами преобразования. Это нам пригодится еще и для настройки преобразований. Пишем функцию-помощницу 'visualize_samples', чтобы показать случайные образцы из нашего датасета. Matplotlib работает только на CPU, так что надо вернуть данные обратно на процессор с помощью .cpu(). В разных фреймворках есть свои особенности, и в pytorch изображения обычно представлены в формате (C, H, W), где C - это количество каналов, например, для нашего датасета Tiny ImageNet это RGB, так что 3. H и W - это высота и ширина. А функция imshow от matplotlib ждет изображения в формате (H, W, C). Можно использовать permute(), чтобы поменять местами оси тензора pytorch с параметрами 1, 2, 0.
Если вглядеться в код, то можно найти знакомые, но странные числа - это обратное преобразование нормализации, которое мы применили ранее. Если мы хотим показать изображения в исходном виде, мы применяем обратную нормализацию, чтобы вернуть изображения из нормализованного диапазона обратно к исходному диапазону пикселей 0-255. Но когда мы делаем прямой проход, будь то обучение или оценка, мы не будем использовать обратную нормализацию, потому что наша модель создана для работы с нормализованными данными и ожидает входные данные в том же диапазоне, что и во время обучения. А иначе всякие сюрпризы могут поджидать.
Python:
import matplotlib.pyplot as plt
import numpy as np
def build_dataloaders(datasets, batch_size: int = 64):
loader_args = {'batch_size': batch_size, 'num_workers': 4, 'pin_memory': True}
return [DataLoader(dataset, shuffle=is_train, **loader_args) for is_train, dataset in
zip([True, False, False], datasets)]
def visualize_samples(dataset, title, num_samples=5):
fig, axes = plt.subplots(1, num_samples, figsize=(15, 3))
for ax in axes:
idx = np.random.randint(len(dataset))
image, _ = dataset[idx]
# CxHxW to HxWxC format for matplotlib imshow
image = image.permute(1, 2, 0)
# Reverse normalization
mean = np.array([0.485, 0.456, 0.406])
std = np.array([0.229, 0.224, 0.225])
image = image.numpy() * std + mean
image = np.clip(image, 0, 1)
ax.imshow(image)
ax.axis('off')
plt.suptitle(title)
plt.show()
image_net = TinyImageNet(DATA_DIR)
image_net.download()
train_data, val_data, test_data = image_net.load_dataset()
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
train_loader, val_loader, test_loader = build_dataloaders([train_data, val_data, test_data], batch_size=64)
for name, dataset, loader in [("Train", train_data, train_loader),
("Validation", val_data, val_loader),
("Test", test_data, test_loader)]:
print(f"\n{name} Dataset:")
print(f"Number of samples: {len(dataset)}")
print(f"Shape of one data sample (C, H, W): {dataset[0][0].shape}")
print(f"{name} loader length: {len(loader)}")
if name in ["Train", "Validation"]:
visualize_samples(dataset, f"{name} Samples")
Теперь пишем несколько сверточных и деконволюционных слоев подряд. Воспользуемся размером ядра в 3x3, потому что практика показала его эффективность. Как доказали такие фундаментальные архитектуры, как VGGNet, маленькие ядра могут создавать равнозначные рецептивные поля с меньшим количеством параметров. Несколько маленьких фильтров может имитировать эффект больших фильтров, но с меньшим вычислительным бременем. Я поставил шаг равным 1 на начальных слоях, чтобы сохранить пространственное разрешение изображения, что поможет вытаскивать более мелкие детали. Продвигаясь вглубь сети, используем шаг 2, чтобы уменьшить пространственные размеры, фактически уменьшая карты признаков, при этом увеличивая рецептивное поле. Не забудьте, что для 3x3 ядра отступ установлен равным 1, чтобы сохранить пространственные размеры.
После кодирования пространственные размеры уменьшились, и теперь нам нужно их восстановить на этапе декодирования, вот тут и пригодятся транспонированные свертки (деконволюции). Такой выбор стратегичен в сценариях, когда нам нужно обучить фильтры апсемплинга непосредственно на данных, а не использовать детерминированные методы типа билинейного или метода ближайших соседей. Изначально на этапе кодирования глубина канала увеличивается с 3 (RGB) до 32, затем до 64 и, наконец, до 128. Этот рост позволяет сети преобразовывать три основных входных канала в более абстрактное представление, возможно, выявляя сложные паттерны и иерархии в данных. Не забудьте в следующем разделе добавить визуализации для этого. Мы все знаем, как выглядит RGB, немного напоминающий теорию цвета Ньютона, но со зловредным зеленым вместо желтого. А что эти абстракции средних слоев представляют? Нужно дебажить, чтобы заглянуть в "голову" нашей модели и узнать, что за фокусы этот малый вытворяет на учебе или будет ли он употреблять "цифровые наркотики"! Напомни мне, если забуду проверить. Наш декодер, наоборот, идет по обратному пути, превращая эти глубокие абстракции обратно в формат из 3 каналов (неважно, какой именно, но это будет что-то вроде RGB из-за функции стоимости).
Мы используем простую активацию tanh между каждым слоем. Поверь на слово, tanh в некоторых старых генеративных моделях работает отлично, потому что центрирует данные вокруг 0 (форма S-кривой), что поможет нам при обратном распространении ошибки. Позже будем дебажить это, чтобы удостовериться, что все идет как надо. Если вы заметите, активация tanh не применяется на выходном слое. Это потому, что мы хотим, чтобы наша сеть могла генерировать RGB изображения со значениями во всем диапазоне пикселей. Если бы активация tanh осталась, значения пикселей были бы жестко усечены между -1 и 1, что может не подойти для некоторых задач.
Теперь мы создаем нашу первую модель. Для функции потерь используем среднеквадратичную ошибку и называем ее "criterion". В начале применим простой градиентный спуск, а потом можем переключиться на оптимизатор Adam. Так, всё готово для обучения! Но прежде чем начать тренировать, нужно проверить мощность нашей модельки. Уверен, многие, кто сталкивался с ChatGPT или другими LLM моделями, слышали про такие числа, как 175 миллиардов параметров, 67 миллиардов параметров. Это общее количество обучаемых параметров в нашей модельке (веса и смещения) - самый простой способ понимать мощность модели. Это не только влияет на время обучения, требования к памяти, но и может привести к переобучению или недообучению.
Но знаете что? Хочу рассказать один секретик: ключ к профессионализму в глубоком обучении - понимать, что мощность модели не равносильна интеллекту. Просто наслаивая слой за слоем и сидя с 999999999 NVIDIA A100 GPUs, триллионами параметров, не создашь сверхразума. Так что не ждите, что GPT-12 напишет вам вирус с нуля. Лучший пример - поговорите минут 10 с GPT-3.5 или GPT-4, и все станет ясно. С этим бредовым товарищем все понятно, пока он не начнет рассказывать анекдоты про Васю и медведя!
Python:
from torch import nn, optim
class ConvNet(nn.Module):
def __init__(self):
super(ConvNet, self).__init__()
self.encoder = nn.Sequential(
nn.Conv2d(3, 32, kernel_size=3, stride=1, padding=1), # Output: 32 x H x W
nn.Tanh(),
nn.Conv2d(32, 64, kernel_size=3, stride=2, padding=1), # Output: 64 x H/2 x W/2
nn.Tanh(),
nn.Conv2d(64, 128, kernel_size=3, stride=2, padding=1), # Output: 128 x H/4 x W/4
nn.Tanh()
)
self.decoder = nn.Sequential(
nn.ConvTranspose2d(128, 64, kernel_size=3, stride=2, padding=1, output_padding=1), # Output: 64 x H/2 x W/2
nn.Tanh(),
nn.ConvTranspose2d(64, 32, kernel_size=3, stride=2, padding=1, output_padding=1), # Output: 32 x H x W
nn.Tanh(),
nn.Conv2d(32, 3, kernel_size=3, stride=1, padding=1), # Output: 3 x H x W
)
def forward(self, x):
x = self.encoder(x)
x = self.decoder(x)
return x
model = ConvNet()
criterion = nn.MSELoss()
SGDoptimizer = optim.SGD(model.parameters(), lr=0.001)
print(sum(p.numel() for p in model.parameters()))
Давайте налепим здесь, небольшую утилитку-тренеровочку, которая будет нашим боссом по обучению и валидации модельки. Сразу же, как только дверь в конструктор захлопывается, модель берется за ушко и пихается на устройство model.to(device), чтобы потом не пришлось носиться с лишними данными, как ведьма на метле (особенно чтобы это не мешало в процессе обучения, особенно на системах производственного уровня). Используем словарик 'dataloaders', чтобы в дальнейшем можно было легко расширять классик и добавлять разные типы данных, например, 'test'.
Теперь давайте наколдуем точку входа в наш класс, чтобы начать учить модель. Используем вложенный tqdm, чтобы в реальном времени наблюдать за потерями, скоростью итераций и прогрессом. Каждая полоска будет за эпоху, что по сути будет показывать, насколько мы продвинулись по итерациям в каждой эпохе. Теперь пишем маленькую функцию-секретику _run_epoch, которая будет крутиться в каждой эпохе, считать потери, делать backpropagation и обновлять весики. Помните, я мог бы засунуть все это в предыдущий метод обучения, но почти тот же процесс, но с небольшими изменениями нужен для валидации и тестирования. Единственное отличие обучения от тестов - нет необходимости следить за градиентами при тестировании, потому что нам не нужно делать backpropagation потерь, и мы не обновляем веса, мы просто смотрим, как себя ведет наша модель. Backpropagation и обновление весов - это как пельмени с мясом на праздник: вкусно, но дорого по ресурсам. Так что нет смысла следить за градиентами для необучающих задач. С pytorch мы просто переключаем модель в режимы model.train() или model.eval() с контекстами torch.enable_grad или no_grad. И помните, это не единственная причина. Еще одна причина - некоторые слои, например BatchNorm, которые мы тут не используем, ведут себя по-разному в зависимости от того, тренируем мы или тестируем.
Python:
class Trainer:
def __init__(
self,
model: nn.Module,
dataloaders: dict,
criterion: nn.Module,
optimizer: optim.Optimizer,
device: str
):
self.model = model.to(device)
self.train_loader = dataloaders['train']
self.val_loader = dataloaders['val']
self.criterion = criterion
self.optimizer = optimizer
self.device = device
def train(self, num_epochs: int):
train_losses, val_losses = [], []
for epoch in tqdm(range(num_epochs), desc="Epochs"):
with tqdm(total=len(self.train_loader), desc="Training", position=0, leave=True) as tqdm_bar:
train_loss = self._run_epoch(self.train_loader, True, tqdm_bar)
val_loss = self._run_epoch(self.val_loader, False)
train_losses.append(train_loss)
val_losses.append(val_loss)
self._log_epoch_progress(epoch, num_epochs, train_loss, val_loss)
self._dynamic_plot(train_losses, val_losses)
self._visualize_samples()
return train_losses, val_losses
Потери модели после вывода считаются с помощью нашего критерия потерь (среднеквадратичная ошибка) против входных данных, потому что наша цель - как можно точнее воссоздать изображение. Отсюда и далее все будет только по тренировкам, как я уже говорил: мы чистим старые градиенты из предыдущего шага с помощью optimizer.zero_grad() чтобы не накапливались, это очень важно перед backpropagation. Следующий шаг - backpropagation потерь через все тензоры, которые привели к расчету потери в первую очередь. Используем backward() на потерях, потому что pytorch следит за всем. Затем веса обновляются на основе производных (градиентов) в зависимости от оптимизатора (здесь SGD со скоростью обучения 0.001) с помощью optimizer.step(). Все потери за эпоху накапливаются в epoch_loss.
Python:
def _run_epoch(self, dataloader: DataLoader, training: bool, tqdm_bar=None) -> float:
if training:
self.model.train()
context = torch.enable_grad
else:
self.model.eval()
context = torch.no_grad
epoch_loss = 0.0
with context():
for inputs, _ in dataloader:
inputs = inputs.to(self.device)
outputs = self.model(inputs)
loss = self.criterion(outputs, inputs)
if training:
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()
tqdm_bar.set_postfix(loss=loss.item())
tqdm_bar.update()
epoch_loss += loss.item()
return epoch_loss / len(dataloader)
Теперь закомментированные строки из метода 'train' надо раскомментировать после того, как напишем эти вспомогательные функции. _log_epoch_progress для логирования train_loss и val_loss после каждой эпохи, чтобы мы могли следить за тем, учится ли модель или сошла на нет. Потом напишем функцию для динамического отображения изменения потерь со временем. Затем нам нужно постоянно проверять, насколько хорошо наша модель работает на валидационном наборе (не финальный тестовый набор, к которому мы не будем прикасаться до самого конца). И помни, мы не обучаемся на валидационных данных и уж тем более не на тестовых (это было бы глупо), все похоже на вычисление потерь. Предыдущий метод, который запускает эпоху, будет вызван, и контекст модели будет установлен на eval, причины я уже объяснил раньше. И так как нам, людям, нужны визуальные доказательства, чтобы в что-то верить, мы напишем коротенький статический метод _plot_img. Только помни две вещи: сначала отключи тензор от его вычислительного графа, перенеси на CPU и изменяй порядок, чтобы он соответствовал ожидаемому формату ввода plt.imshow(). Я уже объяснил все это подробно раньше.
Python:
def _log_epoch_progress(self, epoch: int, num_epochs: int, train_loss: float, val_loss: float):
print(f"Epoch {epoch+1}/{num_epochs} - Training loss: {train_loss:.4f}, Validation loss: {val_loss:.4f}")
def _dynamic_plot(self, train_losses: list, val_losses: list):
plt.clf()
plt.plot(train_losses, label='Training loss')
plt.plot(val_losses, label='Validation loss', linestyle='dashed')
plt.legend()
plt.xlabel('Epochs')
plt.pause(0.01)
def _visualize_samples(self):
inputs, _ = next(iter(self.val_loader))
outputs = self.model(inputs.to(self.device))
plt.figure(figsize=(6, 2))
for idx in range(3):
self._plot_img(inputs[idx], title='Input', position=idx+1)
self._plot_img(outputs[idx], title='Output', position=idx+4)
plt.tight_layout()
plt.show()
@staticmethod
def _plot_img(img_tensor: torch.Tensor, title: str, position: int):
plt.subplot(2, 3, position)
plt.imshow(img_tensor.cpu().detach().permute(1, 2, 0))
plt.title(title)
plt.axis('off')
dataloaders = {"train": train_loader, "val": val_loader}
trainer = Trainer(model, dataloaders, criterion, SGDoptimizer, device)
train_losses, val_losses = trainer.train(num_epochs=10)
Epoch 1:
Epoch vs Loss:
Epoch 10:
ONNX Runtime
Итак, допустим, нам захотелось использовать нашу модель в программе на C/C++ для инференса. Используем формат ONNX (Open Neural Network Exchange). Это, по сути, общее представление и операции над моделями для разных фреймворков. Метод torch.onnx.export позволяет сохранить модель PyTorch в формате ONNX. Для процесса экспорта нам нужен образец входных данных, чтобы определить формы и другие детали. Этот ввод должен иметь такую же форму, как и стандартные вводы нашей модели. Но стоп! Здесь косяк, правда? Нам нужны динамические формы ввода во время инференса. Ведь в чём смысл, если мы можем провести стеганографию только на изображении фиксированного размера? Это просто ужас. Из-за этого нам нужно использовать динамические оси в функции экспорта, таким образом определенные размеры наших входных тензоров будут динамически регулироваться. У нас это высота и ширина. Но вам ещё предстоит узнать об этом. Нам все равно нужен фиктивный входной тензор (знаю по себе, как я бился с этим вопросом, когда начал работать с ONNX) для трассировки. Этот тензор не задает фиксированный размер, он служит примером для нашей функции экспорта. Да, это правда даже если вы добавили параметр dynamic_axes, все равно требуется входной тензор для процесса трассировки. Это не зафиксирует размер ввода нашей модели, это просто способ для ONNX понять модель во время экспорта.
Для инференса на нашей модели ONNX мы будем использовать ONNX Runtime. У ONNX Runtime нет официального PPA или apt репозитория для прямой установки. Можно собрать его из исходного кода, но мы такими героическими поступками заниматься не будем. Лично я просто скопировал заголовки и библиотеки из скачивания в папку проекта и экспортировал библиотеку в переменную. Это было плохо. Я все почистил и теперь сплю спокойно. Больше никогда не буду работать с ONNX на моем ноутбуке. Интересно, ONNX действительно хорош при сборке из исходников или у них просто нет людей, которые умеют собирать под Windows? В общем, вам нужно правильно настроить зависимости для нашего кода на C++. Если что-то непонятно или у кого-то есть крутые трюки - пишите в комментариях, обсудим лучшие методы работы с ONNX Runtime.
Ключ к хорошему инференсу - подготовка данных. OpenCV - это круть в операциях обработки изображений. Сначала определим несколько обычных констант для размеров нашего изображения и каналов в IMAGE_SIEZ и NUM_CHANNELS. Создадим простую функцию LoadAndPreprocess, чтобы подготовить изображение для инференса. Сначала загрузим изображение с помощью cv::imread в цветном формате, затем изменяем размер согласно нашим требованиям с помощью cv::resize. Но есть одна тонкость: по умолчанию OpenCV использует порядок каналов BGR, а стандарт глубокого обучения - RGB. Так что преобразуем его с помощью cv::cvtColor. Мы напишем маленькую функциючку для конвертации 2D картинки в 1D вектор, беря наш cv::Mat и выдавая ImageData (что есть вектор с типом float). Помни, мы делаем эту сериализацию рядок за рядком, канал за каналом. А потом можем лепить из этого 1D вектора N-D ONNX тензор, создав ONNX тензор в функции Run внутри класса OnnxModel. Это использует тот самый расплющенный img_data, но на деле дает ему 4D форму, которую мы определили с input_shape, когда на самом деле создавали тензор (batch size, channels, height, width) и опять же вернулись к стандартам глубокого обучения.
C++:
#include <iostream>
#include <vector>
#include <string>
#include "include/cpu_provider_factory.h"
#include "include/onnxruntime_cxx_api.h"
#include <opencv2/opencv.hpp>
namespace ModelInference {
constexpr int IMAGE_SIZE = 64;
constexpr int NUM_CHANNELS = 3;
using ImageData = std::vector<float>;
class ImageProcessor {
public:
static cv::Mat LoadAndPreprocess(const std::string& imagePath) {
cv::Mat img = cv::imread(imagePath, cv::IMREAD_COLOR);
if (img.empty()) {
throw std::runtime_error("img not found/unsupport format");
}
cv::resize(img, img, {IMAGE_SIZE, IMAGE_SIZE});
cv::cvtColor(img, img, cv::COLOR_BGR2RGB);
return img;
}
static ImageData ConvertToTensorData(const cv::Mat& img) {
ImageData img_data(NUM_CHANNELS * IMAGE_SIZE * IMAGE_SIZE);
for (int i = 0; i < img.rows; ++i) {
for (int j = 0; j < img.cols; ++j) {
cv::Vec3b pixel = img.at<cv::Vec3b>(i, j);
for (int c = 0; c < NUM_CHANNELS; ++c) {
img_data[c * IMAGE_SIZE * IMAGE_SIZE + i * IMAGE_SIZE + j] = static_cast<float>(pixel[c]);
}
}
}
return img_data;
}
static cv::Mat ConvertFromTensorData(float* tensorData) {
cv::Mat output_img(IMAGE_SIZE, IMAGE_SIZE, CV_8UC3);
for (int i = 0; i < IMAGE_SIZE; ++i) {
for (int j = 0; j < IMAGE_SIZE; ++j) {
for (int c = 0; c < NUM_CHANNELS; ++c) {
output_img.at<cv::Vec3b>(i, j)[c] = static_cast<uchar>(tensorData[c * IMAGE_SIZE * IMAGE_SIZE + i * IMAGE_SIZE + j]);
}
}
}
return output_img;
}
};
В классе OnnxModel у нас есть экземпляры Ort::Env и Ort::Session. Они тут как среда выполнения и сессия вывода. Создаем такой забавный приватный методчик для задания опций сессии. Устанавливаем количество потоков в 1. Вообще, ONNX Runtime мне кажется сложнее, чем pytorch и tensorflow. Я старался от него держаться подальше, потому что мне он на фиг не нужен - все, что я делаю в работе, я делаю с PyTorch. Есть отдельные инженеры, которые занимаются такой интеграцией ONNX Runtime. Но насколько я в этом разбираюсь, вот что могу сказать. ONNX Runtime предлагает кучу запутанных настроек и оптимизаций для вывода (а про обучение даже не говорю, это целый океан). Эти настройки упакованы в объект Ort::SessionOptions. В нашем методе устанавливаем intra operation thread count в 1, это значит, что операции внутри графа ONNX будут использовать один поток, но можно настроить и по-другому. Еще знаю методчик 'SetGraphOptimizationLevel', который включает дополнительные оптимизации с какой-то магией, до которой мне далеко.
В двух словах, для тех, кто запутался в этом C++: класс 'OnnxModel' - это мостик между нашими функциями обработки изображений и ONNX Runtime. При инициализации настраиваем 'env', загружаем модель через 'session'. Вывод будет выполняться методом 'Run', загружаем один раз. И результатик будет ждать вас в Ort::Value.
Если хочешь встроить модель прямиком в бинарник, можешь конвертировать ее с помощью xxd в Сишный массив и прикрутить к бинарнику. Я лично не пробовал, но так-то это путь. Можешь даже попробовать сжать его простеньким zlib, встроить через xxd и распаковать на лету.
C++:
class OnnxModel {
Ort::Env env;
Ort::Session session;
static Ort::SessionOptions CreateSessionOptions() {
Ort::SessionOptions session_options;
session_options.SetIntraOpNumThreads(1);
session_options.SetGraphOptimizationLevel(ORT_ENABLE_EXTENDED);
return session_options;
}
public:
OnnxModel(const std::string& model_path)
: env(ORT_LOGGING_LEVEL_WARNING, "ModelInference"),
session(env, model_path.c_str(), CreateSessionOptions()) { }
Ort::Value Run(const cv::Mat& img) {
auto img_data = ImageProcessor::ConvertToTensorData(img);
std::vector<int64_t> input_shape = {1, NUM_CHANNELS, IMAGE_SIZE, IMAGE_SIZE};
Ort::MemoryInfo memory_info = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault);
Ort::Value input_tensor = Ort::Value::CreateTensor<float>(memory_info, img_data.data(), img_data.size(), input_shape.data(), 4);
const char* input_names[] = {"input"};
const char* output_names[] = {"output"};
auto outputs = session.Run(Ort::RunOptions{nullptr}, input_names, &input_tensor, 1, output_names, 1);
return std::move(outputs.front());
}
};
} // namespace ModelInference
int main() {
try {
ModelInference::OnnxModel model("model.onnx");
auto img = ModelInference::ImageProcessor::LoadAndPreprocess("test.jpeg");
auto output = model.Run(img);
auto output_img = ModelInference::ImageProcessor::ConvertFromTensorData(output.GetTensorMutableData<float>());
cv::imwrite("final_output.jpeg", output_img);
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
return 0;
}
Что произойдет, если секретная картинка и картинка-обложка займутся любовью? Рождается новая картинка-обложка, а секретная картинка - как паразит внутри!
Так вот, мы уже видели, что нейросеть может через ряд свёрточных операций выдать ту же самую картинку. А давайте представим себе, если бы мы обучили модель принимать обложку и секретную картинку, а потом заставили ее выдать такую же обложку, но с маленьким секретом внутри? Это замечательная идея автоэнкодеров. Но тут у нас появляется куча вопросов: как сохранить секрет? Какая будет функция потерь? Хитрая фишка здесь - использовать картинки против самих себя. Возвращаясь к нашей модели, создадим две сети: первая будет принимать обложку и секретную картинку и выдавать похожую обложку, допустим, X. Потом этот X пихаем во вторую сеть, чтобы получить обратно секретную картинку. Но это будет работать только если в X где-то затаился наш секретик. В нейросетях, друзья мои, если хочешь, чтобы твоя сеть была послушной, нужно правильно определить функцию потерь. У нас их будет две: L1 для первой сети по сравнению с обложкой и L2 для обеих сетей по сравнению с секретной картинкой.
Достаточно болтовни, давайте к делу. Сначала создадим первую сеть (энкодер), похожую на первую, но с небольшими ухищрениями. Начнем просто - добавим немного "шума" к нашей картинке-обложке. Этот "шум" и будет нашей секретной картинкой. А может быть, использовать одну и ту же картинку для всех данных? Ну это ж как русская рулетка, может обернуться переобучением. Поэтому лучше просто перемешать наши картинки-обложки и использовать их как секретные. Если вы все-таки решите использовать одну картинку, не забудьте добавить размер партии: {batch_size} + (3, 64, 64). Возможно, вы думаете о других размерах картинок. Это классический вопрос стеганографии: если размер секретной картинки меньше, чем у обложки, можно добавить заполнение по краям - дело двух строчек кода. И помните: так как секретные картинки созданы из входных картинок-обложек на GPU, их производные автоматически будут на этом же устройстве. Протестируем, можем ли мы получить обратно картинку-обложку, учитывая, что нейросеть считает секретную картинку "шумом". В обычных нейросетях данные идут последовательно, но здесь на первом слое conv1 мы передаем только секретные картинки и применяем N фильтров. Затем используем torch.cat, чтобы объединить преобразованные фильтры секретных картинок (batch_size, N, 64, 64) с картинками-обложками (batch_size, 3, 64, 64) по измерению 1. В общем, все просто: нам нужно (N+3) изображений размером 64x64 пикселя. Если что не ясно - добро пожаловать в комментарии!
Извлечение признаков без уменьшения разрешения
Сначала мы вытащим наиболее важные черты нашего секретного изображения. Не путайте это с входными данными (изображениями-обложками). Если раньше мы использовали свёртки для изображений-обложек, то теперь – только для секретных изображений. И постепенно увеличиваем глубину канала с 3 до 32, затем до 64, а потом до 128. Это гарантирует богатое иерархически структурированное представление. А теперь внимание, братцы! Мы будем использовать три сверточных слоя для извлечения признаков, при этом сохраняя пространственные размеры за счет шага в 1 и подходящего пэддинга. Наша цель не столько распознавание образов, сколько извлечение признаков и их трансформация для последующего смешивания (объединения особенностей обложки и секретных изображений). Сохраняя пространственные размеры (высоту и ширину), мы гарантируем, что локализованная информация из секретных изображений остается неизменной, что позволяет более точно смешивать признаки с изображениями обложек. Первые слои, как правило, фиксируют базовые черты, такие как края, текстуры, в то время как глубокие слои улавливают более сложные абстракции. Об этом мелком фишечном делении в модели поговорим в следующих разделах. Увеличивая глубину, сеть получает больше возможностей для кодирования различных атрибутов секретных изображений, которые в дальнейшем можно использовать при слиянии.
Как я и говорил раньше, признаки теперь объединяются с помощью команды torch.cat([tensors], dim=1) вдоль dimension=1, что соответствует каналу. В итоге получается 131 канал. А теперь – к нашему декодеру, который генерирует встроенное изображение-обложку снова (не декодер, извлекающий секретное изображение). У нас нет уменьшения разрешения в фазах извлечения признаков, поэтому мы не будем использовать транспонированные свертки. Вместо этого построим серию сверток, которые уточняют комбинированные признаки для создания нашего окончательного изображения. Постепенное сокращение глубины канала (с 131 до 128, затем до 64 и до 3) помогает агрегировать широкое пространство признаков в более компактное и значимое представление, подходящее для воссоздания нашего RGB изображения.
Python:
class ConvNet(nn.Module):
def __init__(self):
super(ConvNet, self).__init__()
self.feature_extractor = nn.Sequential(
nn.Conv2d(3, 32, kernel_size=3, stride=1, padding=1),
nn.Tanh(),
nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=1),
nn.Tanh(),
nn.Conv2d(64, 128, kernel_size=3, stride=1, padding=1),
nn.Tanh()
)
self.decoder = nn.Sequential(
nn.Conv2d(131, 128, kernel_size=3, stride=1, padding=1), # 131 = 3 (inputs) + 128 (feature_extractor)
nn.Tanh(),
nn.Conv2d(128, 64, kernel_size=3, stride=1, padding=1),
nn.Tanh(),
nn.Conv2d(64, 3, kernel_size=3, stride=1, padding=1)
)
def forward(self, inputs, inputs2):
features = self.feature_extractor(inputs2)
combined = torch.cat((inputs, features), dim=1)
output = self.decoder(combined)
return output
Python:
def _run_epoch(self, dataloader: DataLoader, training: bool, tqdm_bar=None) -> float:
if training:
self.model.train()
context = torch.enable_grad
else:
self.model.eval()
context = torch.no_grad
epoch_loss = 0.0
with context():
for inputs, _ in dataloader:
inputs = inputs.to(self.device)
random_indices = torch.randperm(inputs.shape[0])
secret_images = inputs[random_indices]
outputs = self.model(inputs, secret_images)
loss = self.criterion(outputs, inputs)
if training:
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()
tqdm_bar.set_postfix(loss=loss.item())
tqdm_bar.update()
epoch_loss += loss.item()
return epoch_loss / len(dataloader)
def _visualize_samples(self):
inputs, _ = next(iter(self.val_loader))
inputs = inputs.to(self.device)
random_indices = torch.randperm(inputs.shape[0])
secret_images = inputs[random_indices]
outputs = self.model(inputs, secret_images)
plt.figure(figsize=(6, 2))
for idx in range(3):
self._plot_img(inputs[idx], title='Input', position=idx+1)
self._plot_img(outputs[idx], title='Output', position=idx+4)
plt.tight_layout()
plt.show()
Epoch 1:
Epoch vs Loss:
Epoch 10:
Продолжение статьи...
(c) RodionRaskolnikov
для https://xss.pro