- BrainTools - https://www.braintools.ru -

Как я учил компьютер понимать 122 000 фотографий — и почему сложностью оказались не нейронки, а слова

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

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

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

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

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

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

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

Ну и поехали.

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

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

  • Минимум 200 тегов

  • 99%+ фото должны получить хотя бы один тег

  • Не более 3% фото с одинаковым набором тегов

  • Precision и recall — ориентир 90%

  • Работает локально, на CPU

  • Теги должны быть понятны обычному человеку без инструкции

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

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

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

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

Я поначалу думал – ну, классификации, embedding, 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]. Огромная разница между тем, как интернет по алгоритму классифицирует фотографии, и тем, как человек для себя их обозначает. И вот тут мы с заказчиком пришли к выводу, что нельзя найти в интернете готовый ответ на вопрос «как люди для себя классифицируют фотки». Либо данных нет, либо их дистилляция заняла бы столько времени, что это был бы отдельный проект.

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

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

Оси памяти

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

  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

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

image → CLIP embedding (ViT-B-32)
tag → набор текстовых промптов
score = cosine_similarity(image, prompt)
if score >= threshold → assign tag

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

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

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

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

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

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

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

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

birthdayweddingnew_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 нужен, но нейтральный фон датасета работает лучше ручного подбора. А для конфликтующих классов – нужен отдельный фильтр постфактум.

Этап 2 — шесть моделей и момент, когда заказчик сказал “это отстой”

Зачем сравнивать, если CLIP работает

Потому что продуктовые ограничения. PyTorch – 300+ МБ. У заказчика уже есть dlib – 5-10 МБ. Если эмбеддинги можно считать через dlib или TFLite – продукт распухнет на 50-80 МБ вместо 700. Это не академический интерес [3] – это натуральное бизнес-требование.

Шесть кандидатов

  • CLIP RN50 – основной кандидат

  • CLIP ViT-L/14 – потолок качества (для контекста, не для прода)

  • MobileNet v3 Small – компактный

  • ResNet50 (ImageNet)EfficientNet-B0

  • dlib модели – ResNet34, ResNet50, ViT-S-16

Первый прогон: красивые цифры, которые ничего не значили

Первую версию сравнения я построил на coverage – какой процент фото получает тег. MobileNet показал лучший coverage. Я был доволен.

Но не заказчик:

Мы померяли охваты моделей, но это не то же самое что recall. У нас нет числовых показателей precision/recall. Сложно так принимать решение о выборе.

И далее:

Честно говоря, цифры немного расстраивают… RN50 конечно лучше, но тоже отстой.

Ну тут важно сказать одну вещь: когда заказчик говорит “отстой” – я не воспринимаю это как претензию к своей работе, я ж не изобретаю нейросеть с нуля. Я делаю research – беру готовые предобученные модели, делаю эмбеддинги, показываю результат. Если результат слабый – это факт о модели, а не результат моей работы. Это отчётность о чем-то фактическом. Я выявил результат, и точка.

Но пинок был правильный. Потому что coverage – действительно не recall. И без честного P/R/F1 нельзя принимать решение.

Как пришлось перестроить всё

  1. Собрал GT-датасет – 5500 фото, размеченных вручную по 15 тегам

  2. Посчитал честный Precision / Recall / F1 для каждой модели

  3. Добавил режимы сравнения: target P ≥ 0.9, target R ≥ 0.8

  4. Добавил метаданные: размер, лицензии, runtime, CPU latency

  5. Прогнал SOTA-ceiling – ViT-L/14

Заказчик еще отдельно ткнул в мою ошибку [4] с dlib:

Почему вы из dlib взяли dlib_face? Это же для распознавания лиц, а не картинок. Там же есть resnet50_1000_imagenet_classifier, vit-s-16. Почему не попробовали их?

Действительно. Я взял face-модель как feature backend – проверить принцип работы dlib. Но нужно было брать classifier-модели. Протестировал дополнительно.

Результат

На GT (5500 фото, 15 тегов):

Модель

Precision

Recall

F1

RN50 (CLIP)

0.466

0.853

0.557

ViT-L/14 (ceiling)

0.644

0.565

0.538

MobileNet v3

0.457

0.712

0.514

dlib ResNet34

0.442

0.450

0.339

dlib ViT-S-16

0.432

0.200

0.203

Три наблюдения:

Первое. Разрыв между RN50 и потолком (ViT-L/14) – небольшой. При этом ViT-L/14 в три с лишним раза медленнее (153 vs 45 мс/фото). Потолок качества подтверждён – но он не настолько далеко, чтобы оправдывать тяжёлую модель.

Второе. dlib провалился. ImageNet-обученные модели дают эмбеддинги под 1000 фиксированных классов, а у нас – multilabel, пользовательские сцены, абстрактные теги типа walk и travel. Заказчик сам это резюмировал:

Они же на 1000 классах учили, а не на контрасте как CLIP

Другая постановка – другие требования к пространству эмбеддингов.

Третье. MobileNet был быстрее и компактнее, но ручная валидация показала: на сложных тегах больше borderline-случаев, однотипные результаты. Когда RN50 путает день рождения со свадьбой – это все таки визуально близко. Когда MobileNet путает день рождения с любым столом с едой – это уже раздражает.

Финальное решение: RN50 (CLIP) через ONNX Runtime. Без PyTorch в продакшене. ~45 мс на фото на CPU. ~77 МБ весов.

Этап 3 – “Меньше экспериментов, больше фиксации”

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

81 тег

Система выросла с 39 до 81 тега по всем шести осям. Места, активности, события, типы контента, люди, качество и условия – от beach до moon, от selfie до stairwell.

Индивидуальные пороги

На первом этапе – один глобальный порог. На третьем – каждый тег получил свой, подобранный по precision-recall кривой. Для beach – высокий (модель уверена). Для birthday – компромисс между “хоть что-то” и “не врать”.

Conflict-фильтр

screenshot и meme – скриншот переписки часто содержит мем. birthday и wedding – модель может поставить оба. Если два конфликтующих тега прошли порог – остается тот, у которого score выше.

Fallback для покрытия

Без fallback – 52.73% фото с тегами. С fallback — 99.07%.

Если ни один тег не прошёл порог – берется лучший тег с минимальным порогом. Это не “точный тег”, а “лучшее предположение”. Но для навигации – лучше, чем ничего. В отчетах я всегда считал эти цифры отдельно. Потому что coverage с fallback – продуктовая метрика. Coverage без – качественная. То есть – не путать их!.

Дополнительный этап – уменьшение размерности

После того как RN50+CLIP через ONNX стал финальным выбором, заказчик вернулся к вопросу, который поднял ещё на этапе 2:

Снижение размерности. 1024-мерный вектор – избыточен для 81 тега. Можно ужать?

Вопрос не праздный: меньше размерность – быстрее retrieval, меньше памяти. Договорились начать с 512 и смотреть, что теряется.
Проверяли два метода: PCA 512d и Random Projection 512d.

Ловушка красивых чисел

Автоматические метрики выдали неожиданную картину

Режим

Precision

Recall

F1

overlap@10

Baseline 1024d

0.823

0.474

0.542

PCA 512d

0.344

0.998

0.484

0.810

RP 512d

0.828

0.440

0.514

0.735

PCA показывает recall 0.998 – почти идеальный. Выглядит как победа. Но precision 0.344 – печаль. Модель начала ставить теги буквально всему. Та же история с coverage: загрубил – получил красивую цифру. Только здесь не намеренно – пространство схлопнулось, все эмбеддинги стали ближе друг к другу, и старые пороги поехали.

RP ведёт себя честнее по агрегатным метрикам, но хуже держит retrieval: overlap@10 = 0.735 против 0.810 у PCA.

По числам непонятно, нужна ручная проверка.

Что показала ручная валидация

Подготовил артефакты для визуального сравнения: для 30+ query-изображений – top-10 соседей в каждом из трех режимов, для сложных тегов (birthdayweddingscreenshotwalk и других) – top-30 кандидатов по score. Около 2000 примеров прошли ручную оценку.

Результат по retrieval – доля визуально осмысленных соседей:

  • baseline: 0.940

  • pca_512: 0.947

  • rp_512: 0.927

PCA не только не деградировал – он чуть лучше держит retrieval, чем baseline. По classification на сложных тегах деградации, заметной для пользователя, не выявлено.

Вывод простой: агрегатные метрики врали. Precision 0.344 – артефакт порогов, которые нужно перекалибровать под новое пространство. Визуальное поведение [5] системы осталось рабочим.

Финал: PCA 512d в продакшене. Вдвое меньше размерность, быстрее retrieval, меньше памяти – без практической потери качества.

Итоговые цифры

GT-оценка (7000 фото, 30 тегов)

  • Precision: 0.654

  • Recall: 0.634

  • F1: 0.566

Полный прогон (122 263 фото)

  • Coverage с fallback: 99.07%

  • Coverage без fallback: 52.73%

  • Среднее тегов на фото: 1.558

  • Уникальных наборов тегов: 2023

Финальная конфигурация

RN50 (CLIP) → ONNX Runtime → PCA 512d. Размер модели ~77 МБ, ~45 мс на фото на CPU, 512-мерный эмбеддинг вместо 1024.

Разбор

F1 = 0.566 – это не 90% из ориентира. Но давайте разберём.

Macro-усреднение считает каждый тег одинаково. Сильные теги (beach, selfie, park) дают 95-100%. Слабые (birthday, eating) тянут среднее вниз. Ищешь “фото с пляжа” – работает отлично. “Фото с дня рождения” – шумнее.
Потолок подтверждён. SOTA-модель (ViT-L/14) на том же GT — F1 = 0.538. Не лучше RN50. Ограничение не в модели, а в сложности самой задачи.
52.73% без fallback – не “модель работает наполовину”, на половине фото модель уверена для жесткого порога. Остальные получают тег через fallback – менее уверенно, но полезно для навигации.

Про LLM в этом проекте

Весь код написан через LLM. Скрипты эмбеддинга, классификации, прогонов, валидации, экспорта в ONNX – все сгенерировано. Я работал в основном с Codex, там пока лимиты повышены на версию 5.3.

Конечно, были тупежи. Самый показательный – на третьем этапе мы с заказчиком четко зафиксировали: работаем с RN50. Все к этому подготовили и я смотрю – а Codex мне генерирует скрипт эмбеддинга на совершенно другую модель. ViT-S-16, вроде как – короче, не RN50. Я ему: “В спецификации конкретно написано, что мы работаем только с RN50”. А он просто проигнорировал контекст и взял что ему показалось подходящим.

Галлюцинации немного удлиняли процесс. Но в целом – все равно это намного быстрее, чем писать все скрипты руками. А их было достаточно много: парсер для сбора фото, эмбеддинги, прогоны по моделям, валидация, визуальные сэмплы, GT-оценка, экспорт ONNX, conflict-фильтр, fallback-логика. Руками это было бы в разы дольше.

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

Это должен понять человек.

Пять вещей, которые я вынес

Сначала таксономия, потом модель. beach работает на 100% не из-за модели, а потому что пляж – визуально однозначная категория. birthday не работает не из-за модели, а потому что день рождения – контекст, а не визуальный паттерн. Если не зафиксирован язык тегов – сравнение моделей бессмысленно.

Zero-shot – лучший разведчик, худший продакшен. Для первого прохода – гениально. За день можно проверить 50 гипотез. Строить на нём продукт – ловушка.

Coverage – опасная метрика. Можно загрубить пороги и показать 95%. А потом пользователь увидит, что на его борще стоит тег beach – и удалит приложение.

Продуктовые ограничения формируют архитектуру. Размер дистрибутива, CPU latency, лицензии – это не “потом разберемся”. Это часть архитектурного решения с первого дня.

Заказчик, который говорит “отстой” – хороший заказчик. Каждый его пинок заставлял меня перестроить что-то конкретное. Каждый раз результат становился лучше. Проект, где заказчик только говорит “ок, супер” — это проект, где никто не проверяет качество.

Что дальше

Проект завершён, развитие – отдельные треки:

Расширение до 200 тегов. Черновая иерархия на 190 готова. Нужен пересбор прототипов и калибровка.

Квантизация. FP16 работает без заметных потерь. INT8 — нужна отдельная валидация.

Снижение размерности. 1024-мерный вектор — избыточен для 81 тега. Заказчик сам предложил гипотезу — можно ужать до 10-20 параметров. Но это отдельный scope, потому что затрагивает ещё и поиск похожих фото.

Усиление сложных тегов. Событийные классы (birthdaytraveleating) требуют не смены модели, а расширения и доочистки прототипов.

Вместо заключения

Фотографии – это не изображения. Это фрагменты памяти. И когда строишь систему навигации по ним – самый важный вопрос не “какая модель лучше”, а “как человек будет это искать”.

Если ответ правильный – даже простой cosine similarity с прототипами дает результат, которым можно пользоваться. Если неправильный – никакой ViT-L/14 не поможет.

Инструмент – усилитель. Но усиливает он то, что ты в него вложил.

Проект выполнен как фриланс-заказ для десктопного приложения каталогизации фотоархивов. Все данные – из реальных отчетов и переписки. Немного пиара моего канала [6]. Если есть вопросы по техничке – пишите в комментариях.

Автор: okoloboga

Источник [7]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/27213

URLs in this post:

[1] память: http://www.braintools.ru/article/4140

[2] обучения: http://www.braintools.ru/article/5125

[3] интерес: http://www.braintools.ru/article/4220

[4] ошибку: http://www.braintools.ru/article/4192

[5] поведение: http://www.braintools.ru/article/9372

[6] канала: https://t.me/post_cybercore

[7] Источник: https://habr.com/ru/articles/1010932/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1010932

www.BrainTools.ru

Rambler's Top100