Предыстория
Полгода назад, ближе к концу первого курса, я стал думать о будущей работе. Возможно на волне хайпа мой выбор пал на Нейронные сети. Начал с классического машинного обучения, а потом нашел хороший курс по свёрточным (CNN) и рекуррентным сетям. CNN меня впечатлили гораздо больше. После пары учебных проектов вроде классификации кошек и собак захотелось сделать что-то сложнее. Так появилась идея: детектировать руку в кадре и определять жест из американского языка жестов (ASL).
Сбор данных и обучение YOLO
Первым этапом стало создание датасета для обучения модели YOLO. Для этого были записаны два исходных двухминутных видео (для обучения и валидации), которые с помощью FFmpeg были разбиты на отдельные кадры. Полученные ~500 изображений были размечены вручную. Однако первый запуск обучения показал, что этого объема данных недостаточно: модель плохо детектировала руку на новом фоне или в нестандартной позе.
Для улучшения результатов был собран расширенный датасет: добавлены еще два видео общей длительностью 12 минут, снятые в различных условиях. После повторной разметки итоговый набор данных составил 3000 изображений.
Финальное обучение модели YOLO на 150 эпохах заняло примерно 2.5 часа на видеокарте RTX 3060 Ti. Общее время на этап сбора данных, разметки и обучения состави��о около 7 часов.

Выбор нейронки для классификации языка жестов
Создание сверточной сети с нуля не имело практического смысла, так как существует множество проверенных и эффективных готовых архитектур.
Думаю разбить их можно примерно на такие группы
Классические: VGG, GoogLeNet, ResNet, DenseNet.
Более современные: EfficientNet, ResNeXt, ShuffleNet.
State-of-the-art: Vision Transformer (ViT), ConvNeXt, Swin Transformer, EfficientNet V2.
Для сравнения я использовал результаты тестов этих сетей на другом датасете (из игры Arma). Хотя данные были другие, это дало общее представление о соотношении скорости и точности.

В топе этого сравнения были ConvNeXt, ViT и Swin Transformer. Поскольку система должна работать в реальном времени, мне нужна была быстрая и относительно легкая модель. Swin Transformer (small) показал лучший баланс скорости, точности и размера. Также я решил попробовать ConvNeXt, чтобы сравнить две современные архитектуры: трансформер и CNN.
Обучение SWIN И ConvNeXt для классификации
Для обучения классификатора жестов я использовал два датасета с Kaggle. Они были объединены в один набор данных для увеличения объема и разнообразия.
1. ASL Alphabet: https://www.kaggle.com/datasets/grassknoted/asl-alphabet
2. Synthetic ASL Alphabet: https://www.kaggle.com/datasets/lexset/synthetic-asl-alphabet
Подготовка моделей с transfer learning
# Для Swin Transformer
model = models.swin_v2_s(weights='DEFAULT')
for param in model.parameters():
param.requires_grad = False # Заморозка всех слоев
model.head = nn.Linear(model.head.in_features, len(train.classes)) # Замена последнего слоя
model.head.requires_grad = True # Разморозка только нового слоя
# Для ConvNeXt
model = models.convnext_tiny(weights='DEFAULT')
for param in model.parameters():
param.requires_grad = False # Заморозка всех слоев
in_features = model.classifier[-1].in_features
model.classifier[-1] = nn.Linear(in_features, len(train.classes)) # Замена последнего слоя
for param in model.classifier[-1].parameters():
param.requires_grad = True
Сам же код обучения выглядит так
def train_model(model, train_data, val_data, epochs=None, checkpoint=None): # функция обучения
model = model.to(device) # Перемещаем модель на устройство
optimizer = optim.AdamW(params=model.parameters(), lr=0.0001, weight_decay=0.001) # Оптимизатор Адам с шагом обучения 0.001 и l2 регуляризацией 0.01
loss_func = nn.CrossEntropyLoss() # Функция потерь
loss_lst = [] # список значений потерь при обучении
loss_lst_val = [] # список значений потерь при валидации
start_epoch = 0
best_val_loss = 1e10
if checkpoint and os.path.exists(checkpoint): # Проверка наличия чекпоинта
checkpoint_dict = torch.load(checkpoint, map_location=device) # Загрузка чекпоинта
model.load_state_dict(checkpoint_dict['model_state_dict']) # Загрузка состояния модели
optimizer.load_state_dict(checkpoint_dict['optimizer_state_dict']) # Загрузка состояния оптимизатора
start_epoch = checkpoint_dict['epoch'] # Начальная эпоха
loss_lst = checkpoint_dict['train_loss_history'] # История потерь обучения
loss_lst_val = checkpoint_dict['val_loss_history'] # История потерь валидации
model.train() # Перевод модели в режим обучения
for epoch in range(start_epoch, start_epoch+epochs): # Начало цикла эпох
loss_mean = 0 # Среднее функции потерь
lm_count = 0 # Счетчик для функции потерь
train_tqdm = tqdm(train_data, leave=True) # Создание прогресс бара
for x_train, y_train in train_tqdm: # Цикл для обучения модели
x_train = x_train.to(device) # Перенос данных на устройство
y_train = y_train.to(device) # Перенос данных на устройство
predict = model(x_train) # Прогноз модели
loss = loss_func(predict, y_train) # Функция потерь
optimizer.zero_grad() # Обнуление градиентов
loss.backward() # Обратный проход для производных
optimizer.step() # Шаг оптимизатора(изменение весов)
lm_count += 1 # Счетчик для функции потерь
loss_mean = 1 / lm_count * loss.item() + (1 - 1 / lm_count) * loss_mean # Среднее функции потерь по формуле экспоненциального скользящего среднего
train_tqdm.set_description(f'Epoch {epoch + 1}/{start_epoch + epochs}, loss_mean: {loss_mean:.3f}') # Описание для прогресс бара
model.eval() # Перевод модели в режим оценки
Q_val = 0 # Средний эмпирический риск
val_count = 0 # Счетчик для среднего эмпирического риска
with torch.no_grad(): # Отключаем вычисление градиентов для валидации
for x_val, y_val in val_data: # Цикл для проверки модели
x_val = x_val.to(device) # Перенос данных на ус��ройство
y_val = y_val.to(device) # Перенос данных на устройство
predict_val = model(x_val) # Прогноз
loss_q = loss_func(predict_val, y_val) # Функция потерь (исправлено имя переменной)
Q_val += loss_q.item() # Добавление значения функции потерь
val_count += 1 # Счетчик для среднего эмпирического риска
Q_val /= val_count # Подсчет среднего эмпирического риска
loss_lst.append(loss_mean) # Добавление значений функции потерь для тестовой выборки
loss_lst_val.append(Q_val) # Добавление значений эмпирического риска для валидационной выборки
torch.save({ # Сохранение чекпоинта
'epoch': epoch + 1,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'train_loss_history': loss_lst,
'val_loss_history': loss_lst_val,
'best_val_loss': best_val_loss,
}, f'hand_model_on_{epoch + 1}_epoch_CNN_new.tar')Swin Transformer small
Время обучения: 5 часов (20 эпох).
Итоговая точность (accuracy): 0.86.
На графике обучения наблюдается резкий скачок на 11-й эпохе, что связано с обучением в два прохода по 10 эпох

ConvNeXt tiny
(график которой к сожалению не сохранился)
Время обучения: 3 часа (20 эпох).
Показал более стабильную сходимость. На 20-й эпохе значение функции потерь (loss) составило 0.2-0.3, а итоговая точность — 0.91.
Первый запуск проекта
Вот и момент истины обученная YOLO для поиска руки и ConvNeXt для классификации жестов были готовы. Я быстро собрал работающий прототип на основе кода из ChatGPT и запустил его.
Результат оказался разочаровывающим. Система работала, но очень плохо: почти все жесты она определяла как букву F, многие жесты вообще не распознавались. В лучшем случае угадывала правильно только в половине попыток, да и то не для всех букв.
В чём была ошибка? Проблема оказалась в несоответствии данных. Всё просто: когда я учил классификатор — я показывал ему фотографии рук на фоне (как в исходном датасете). А когда система работала — YOLO вырезала из кадра только саму кисть и передавала классификатору уже обрезанное изображ��ние без фона. Классификатор просто не был готов к таким «крупным планам» и терялся, отсюда и низкая точность.
Исправление ошибки
Возникшую проблему нужно было решить одним из двух способов: либо научить YOLO обрезать руку так, как в исходных данных, либо заново обучить ConvNeXt на обрезанных изображениях. Первый вариант был неподходящим, так как жесты зависят именно от положения кисти, а не всей руки.
Я выбрал второй путь: автоматизировать процесс обрезки. Уже обученная YOLO была дообучена на изображениях рук из датасета ASL, после чего использовалась для обрезки всех изображений. Это позволило создать новый датасет, где каждое изображение содержало только кисть, аналогично тому, что передавалось в рабочем режиме.
Также был расширен состав датасета: добавлено около 15 000 собственных фотографий рук (по 500 для каждого жеста) и отдельный класс для пробела.

После повторного обучения ConvNeXt на новом датасете были получены значительно улучшенные результаты. В третий раз обучения модель показала лучшую сходимость, а итоговая точность (accuracy) достигла 0.96, что стало рекордным показателем для данного проекта.
Итог
Тестирование на отдельных фотографиях показало хорошие результаты. Главной проверкой стала работа системы в реальном времени — с распознаванием жестов с веб-камеры. И вот мой первый Hello world в мире ИИ.
Конечно, для создания полноценного продукта, способного надёжно работать с разными людьми и в любых условиях, 125 тысяч изображений недостаточно. Потребуется значительно больший и более разнообразный датасет.
Работа над проектом заняла значительно больше времени, чем первые учебные задачи, но оказалась гораздо интереснее и дала ценный опыт на всех этапах — от сбора данных до отладки комплексной системы.
Спасибо, что прочитали мою статью! Надеюсь, вам было интересно следить за развитием этого проекта.
Код для обучения YOLO и ConvNeXt можно найти тут: https://github.com/DargVet/ASL-Classifier-v1
