Как я учил компьютер понимать 120 000 фотографий. Часть 1

Николай Носков 10 мин

Как я вообще туда попал

Я крайне редко на фрилансе получал заказы, связанные с DS/ML: специалистов для таких задач обычно ищут не там. Причины разные: они требуют долгой интеграции, заказчик сам не понимает задачу, DS более конфиденциален, DS часто возникают внутри продукта, да и в последнее время этот сегмент на фрилансе съедается при помощи LLM — AI-интеграции, RAG-боты например. По отдельности эти факторы не страшны, но их совокупность уменьшает количество таких проектов на российском фрилансе почти до нуля.

Но внезапно мне в личку постучались с таким проектом.

Я откликнулся, задал кучу уточняющих вопросов — есть ли датасет, кто размечает, какая модель, где крутится. Заказчик ответил подробно, видно было, что человек понимает задачу изнутри. Он написал:

Датасет надо делать, в этом и смысл задачи. Целевые проценты даны для ориентира, никто не знает, что на самом деле достижимо. Готов принять и худшие результаты, если они объективно обоснованы.

Вот это порадовало! Человек хочет не красивых цифр, а честный результат. Предложил 140к, три этапа, поэтапная оплата: сделали этап, сдали, оплатили. Если что-то не устроит — можно уйти к другому разработчику с результатами. Так риски меньше для обеих сторон. Он принял:

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

Ну и поехали.

Требования, от которых голова кругом

На бумаге всё выглядело чётко:

  • Минимум 200 тегов
  • 99%+ фото должны получить хотя бы один тег
  • Не более 3% фото с одинаковым набором тегов
  • Precision и recall — ориентир 90%
  • Работает локально, на CPU
  • Теги должны быть понятны обычному человеку без инструкции

И вот тут штука, которая определила половину инженерных решений. Заказчик сразу обозначил:

Продукт включает только dlib для распознавания лиц. Он добавляет всего 5-10 МБ. Все модели из OpenCLIP потребуют torch, а он сам что-то вроде 300+ МБ.

То есть нельзя просто взять SOTA-модель и сказать «вот, короче, работает». Она может не влезть в продукт. Это не академическая задача, а продуктовая. А разница между ними — как между «решить уравнение на доске» и «построить мост, который не упадёт».

Почему это не «ещё одна классификация картинок»

Я поначалу думал: ну, классификация, эмбеддинги, cosine similarity, дело техники. Но ошибался.

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

Люди ищут не объекты, а воспоминания. Когда человеку нужно найти фото, он не думает «покажи объекты класса nature». Он думает: «где фото с той прогулки» или «найди фотки с моря», «убери все скрины». Это совершенно другая задача.

Теги должны работать как фильтры. Не «вот 50 тегов на фото, разбирайся сам», а «вот 2-3 точных тега, которые позволяют сузить архив из 100 000 фото до 20». Заказчик сам привёл пример:

Желание найти фото “женщина в красном платье на фоне моря” может быть описано тремя тегами “портрет женщины”, “море”, “яркая одежда”. Этого достаточно чтобы сузить архив из 100 000 фотографий до двух десятков и выбрать нужную глазами.

Красивая постановка. Осталось реализовать.

Как выяснилось, что в интернете нет ответа на главный вопрос

Вот здесь начались отхождения от начального ТЗ.

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

Я сел изучать. И выяснил, что этого не существует.

К чужим Google Photos нет доступа. Нельзя посмотреть, как другие люди организуют свои фотки, какие альбомы создают, по каким словам ищут. Это всё закрыто. Да и в России не много кто намеренно разбирает фотки в Google Photos — скорее они по дефолту иногда туда загружаются. Reddit — просто никто об этом не говорит. Гипотеза о том, что люди в интернете обсуждают, как они классифицируют свои фотки, оказалась неверна. Может, лет двадцать назад это было актуально. Сейчас — увы. Люди не думают про организацию архива, пока у них не накопится 50 000 фото и они не захотят найти конкретное.

На стоках типа Shutterstock, Unsplash, Pexels теги есть, но они коммерческие. Они нужны для SEO, чтобы фото находили в интернете. «Happy», «business», «success» — это маркетинг, а не память. Огромная разница между тем, как интернет по алгоритму классифицирует фотографии, и тем, как человек для себя их обозначает. И вот тут мы с заказчиком пришли к выводу, что нельзя найти в интернете готовый ответ на вопрос «как люди для себя классифицируют фотки». Либо данных нет, либо их дистилляция заняла бы столько времени, что это был бы отдельный проект.

Недолго думая, я решил: сам придумаю. Я же человек. Пообщался с окружающими, с женой, сам с собой подумал — на какие сцены можно разделить фотографии. Не абстрактно, а конкретно: вот я открываю галерею, мне нужно найти фото — о чём я думаю в этот момент? И понял одну важную вещь. Люди мыслят не объектами. Не «красивый лес» и не «два человека». Люди определяют фотографии по месту, по ощущениям, по времени и настроению. «Это было летом на даче». «Это когда мы тусовались в парке». «Это тот вечер в центре, когда шёл дождь». Память работает через контекст, а не через перечисление объектов.

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

Оси памяти

Получилось шесть основных координат:

  1. Тип контента — фото, скриншот, мем, документ, коллаж
  2. Место / сцена — дом, улица, парк, пляж, кафе, горы
  3. Активность — прогулка, спорт, еда, шоппинг, путешествие
  4. Событие — день рождения, свадьба, Новый год
  5. Люди — мужчина, женщина, ребёнок (не конкретные лица — это уже было в продукте)
  6. Качество и условия — тёмное, размытое, засвеченное, дождь, снег

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

Как я собирал 122 000 фотографий

Заказчик сразу сказал: нужно минимум 100 000 фото для тестирования. Свои он давать не готов — они пойдут на валидацию результатов. Предложил: «Возьмите свои фотки и фотки знакомых/родных. Плюс можно поскачивать из интернета».

Я начал с идеи собрать по знакомым. Быстро понял: никто не хочет делиться своими фотками. Это личный архив, там всё — от нелепых селфи до документов. Люди не готовы это отдавать. И даже если бы согласились — это не набрало бы 100 000. То есть суммарно мне скинули около 400 фоток.

Тогда я пошёл в Telegram. Написал парсер на Telethon (userbot на Python), который проходил по каналам, проверял последние 20 000 сообщений и выгружал фотографии. Только открытые каналы, только публичный контент.

Но не случайные каналы. Я уже имел черновое древо тегов и для каждого тега искал тематический канал. Собаки — каналы про собак. Дети — родительские чаты. Тусовки, концерты, еда, путешествия, горы, дача — на каждый тег свой источник. Плюс обязательно каналы, где люди просто делятся своими фото, без тематики. Потому что типичный архив — это не тематическая подборка, а хаос. Много рандомных фото нашёл в источниках типа «фото для фейка», «фото на аву». Были классные каналы фотографов — прям удовольствие местами получил.

Потребовалось около 70 каналов. На выходе — 122 000 изображений: фотографии разного качества, скриншоты, мемы, документы, дубли, случайный мусор. К концу, правда, понял, что для некоторых тегов эталонных фото не хватало — не всё можно найти в открытых каналах. Но для MVP хватило.

Этап 1 — «Мы не оптимизируем. Мы выясняем, где сигнал»

Принцип первого этапа я зафиксировал жёстко: никакой оптимизации. Только проверка — есть ли вообще сигнал? Потому что соблазн «а давайте ещё вот это попробуем» на каждом шагу. И если не ставить чётких границ — будет страшно.

Максимально тупой pipeline

Для первого прохода — простой подход. Без обучения, без fine-tuning. Чистый zero-shot:

Каждый тег описывался несколькими текстовыми промптами. Один глобальный порог. 39 тегов. 122 000 фото. Один вопрос: работает или нет?

Результат удивил

Я взял top-30 фотографий по каждому тегу и глазами проверил.

21 тег — точность 90-100%. Пляж, парк, дом, селфи, еда, прогулка, пикник, транспорт. Без единого обучающего примера. Просто по текстовому описанию.

Ещё 9 тегов — 70-89%. И 9 провалились ниже 70%.

Думал, будет больше шума, а оно работает. На бытовых категориях zero-shot CLIP даёт адекватный результат.

Но тут же вылезла системная проблема

Текстовые промпты оказались слабым звеном.

birthday, wedding, new_year — для CLIP это почти одно и то же. Люди, еда, украшения, помещение. Модель не виновата: она видит визуально похожие сцены, и никакая формулировка промпта не помогает их различить.

document путался с фотографиями книг. screenshot — с селфи в зеркало. meme — со скриншотами переписок.

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

Что сказал заказчик

Заказчик подтвердил приёмку первого этапа и сразу обозначил направление:

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

Заказчик сам предложил ключевой поворот проекта. Не потому что ML-инженер, а потому что продуктовый разработчик, который думал на шаги вперёд. Если мы завязаны на текстовый энкодер CLIP — мы завязаны на весь стек! А это, так-то, 300+ МБ в дистрибутиве.

Вывод первого этапа уместился в одну фразу: сигнал есть, текст — узкое место.

Ключевой поворот: от слов к изображениям

Самый важный архитектурный момент проекта. Он изменил всё.

Было: класс = набор текстовых описаний. Стало: класс = набор эталонных фотографий.

Вместо того чтобы объяснять модели текстом, что такое «пляж», мы показываем ей 120 фотографий пляжей. Модель считает средний эмбеддинг — «центроид класса». Новое фото сравнивается не с текстом, а с этим центроидом.

Что это даёт:

Нет зависимости от текстового энкодера. Можно использовать любую модель, которая превращает картинку в вектор: CLIP, ResNet, MobileNet, dlib — да что угодно.

Нет лексической ловушки. «День рождения» и «свадьба» различаются не по словам, а по визуальным паттернам.

Объяснимость. Можно посмотреть, из каких фото состоит класс. Понять, почему модель ошиблась. Улучшить — добавив или убрав примеры.

Масштабируемость. Новый тег = новая папка с фотографиями. Никакого переобучения.

Провал с hard negatives

Помимо положительного центроида нужен был отрицательный — чтобы штрафовать за «похож на общий фон». Рабочая формула:

score(tag) = cos(image, centroid_positive) - cos(image, centroid_negative)

Это сильно снизило false positives. Но дальше я попробовал hard negatives — вручную подобранные «похожие, но не то». Если beach путается с pool — добавим фото бассейнов как negative.

Результат — жесть. Слишком узкие negatives сломали шкалу скоров: модель начала видеть «пляж» в совершенно случайных фото. Массовый over-tagging. Ну надо запомнить: negative нужен, но нейтральный фон датасета работает лучше ручного подбора. А для конфликтующих классов нужен отдельный фильтр постфактум.

Во второй части — шесть моделей, честные precision и recall вместо coverage, 81 тег, conflict-фильтр и ловушка красивых чисел при снижении размерности.