Telegram-бот для поиска похожих изображений в канале

от
Java   java, telegram, bot, image processing, hash

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

Алгоритм работы бота
Описанный в статье бот, будет иметь самую минимальную, но работающую реализацию.
Запущенный бот добавляется в канал в качестве администратора и с этого момента начинает индексировать все поступающие в канал картинки.
При нахождении дубликатов, администратору бота присылается отчёт с ссылками на посты копий. Так как всегда существует риск ложного срабатывания, решение об удалении картинки принимает сам администратор.
Бот может запускаться раз в час или даже раз в сутки. Как следствие, бот может быть запущен на локальном компьютере, без необходимости в сервере.
Каналов может быть сколь угодно много, для каждого будет вестись своя база данных.

В базе данных хранится хэш картинки и минимальная информация для нахождения поста в канале.

Остальное уже можно дорабатывать по своему усмотрению. Ссылка на репозиторий с исходниками в конце статьи.


Обработчик бота
Для обращения к Telegram API я буду использовать библиотеку java-telegram-bot-api.

  1. // build.gradle
  2. dependencies {
  3.     implementation 'com.github.pengrad:java-telegram-bot-api:4.9.0'
  4. }

  1. public class Main {
  2.     public static void main(String[] args) {
  3.         final String botToken = stringProp("BOT_TOKEN")
  4.                 .orElseThrow(() -> new IllegalArgumentException("BOT_TOKEN is required"));
  5.         final var handler = new BotHandler(botToken);
  6.         handler.setAdminId(longProp("ADMIN_ID").orElse(0L));
  7.         handler.run();
  8.     }
  9.  
  10.     private static Optional<String> stringProp(String name) {
  11.         return Optional.ofNullable(System.getenv(name))
  12.                 .or(() -> Optional.ofNullable(System.getProperty(name)));
  13.     }
  14.  
  15.     private static Optional<Long> longProp(String name) {
  16.         return stringProp(name).map(Long::parseLong);
  17.     }
  18. }

Настройки для бота (токен бота и id администратора) будут задаваться при запуске из переменной окружения (System.getenv) или из системных параметров (System.getProperty).

Основная работа с ботом будет проходить в классе BotHandler.
  1. public class BotHandler {
  2.     private final TelegramBot bot;
  3.     private long adminId;
  4.  
  5.     public BotHandler(String botToken) {
  6.         bot = new TelegramBot.Builder(botToken)
  7.                 .updateListenerSleep(20_000L)
  8.                 .build();
  9.     }
  10.  
  11.     public void setAdminId(long adminId) {
  12.         this.adminId = adminId;
  13.     }
  14.  
  15.     public void run() {
  16.         bot.setUpdatesListener(updates -> {
  17.             // TODO code
  18.         });
  19.     }
  20. }

В методе run() задаётся обработчик обновлений bot.setUpdatesListener. В updates содержится список из максимум 100 объектов Update. Это именно то, что нам нужно! Так как боту необязательно быстро отслеживать постинг в каналы, период обновления можно повысить (в конструкторе я задал updateListenerSleep(20_000L) 20 секунд), хотя можно запускать бота и раз в день на одну минуту, всё зависит от частоты постинга в канал.

Теперь фильтруем из всех апдейтов только картинки в канале:
  1. bot.setUpdatesListener(updates -> {
  2.     final List<Message> channelPosts = updates.stream()
  3.             .map(Update::channelPost)
  4.             .filter(Objects::nonNull)
  5.             .filter(msg -> msg.photo() != null)
  6.             .collect(Collectors.toList());
  7.     // ...
  8. });

Теперь в каждом объекте Message списка channelPosts гарантированно будет массив PhotoSize[], получаемый из метода msg.photo().

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

Здесь кроется возможное улучшение бота. По превью можно также находить похожие видео, анимации или документы (все медиа, у которых доступен PhotoSize getThumb()).
  1. // ... channelPosts
  2. for (var post : channelPosts) {
  3.     final PhotoSize photo = getSmallestPhoto(post.photo());
  4. }
  5. // ...
  6.  
  7. private PhotoSize getSmallestPhoto(PhotoSize[] photoSizes) {
  8.     return Arrays.stream(photoSizes)
  9.             .min(Comparator.comparingInt(ps -> ps.width() * ps.height()))
  10.             .orElse(photoSizes[0]);
  11. }

Минимальное изображение ищется по количеству пикселей, хотя можно и по размеру файла в байтах (ps.fileSize()).

Теперь нужно скачать файл и получить из него BufferedImage. Также для удобства указания поста в канале я создам класс, который будет хранить два значения: id канала и id сообщения:
  1. for (var post : channelPosts) {
  2.     final PhotoSize photo = getSmallestPhoto(post.photo());
  3.     try {
  4.         final var tgFile = bot.execute(new GetFile(photo.fileId())).file();
  5.         final var url = new URL(bot.getFullFilePath(tgFile));
  6.         final BufferedImage image = ImageIO.read(url);
  7.         final var originalPost = new Post(post.chat().id(), post.messageId());
  8.         // TODO image index
  9.     } catch (IOException | SQLException e) {
  10.         System.err.format("Error while processing photo");
  11.     }
  12. }
  13.  
  14. public class Post {
  15.     private final Long channelId;
  16.     private final Integer messageId;
  17.     // constructor, getters
  18.     // equals and hashCode
  19. }

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


Индексирование изображений
Для поиска похожих изображений я воспользуюсь библиотекой JImageHash. В ней есть реализации нескольких алгоритмов хэширования, а также много понятных примеров. Обязательно загляните в репозиторий.

  1. // build.gradle
  2. repositories {
  3.     jcenter()
  4. }
  5.  
  6. dependencies {
  7.     implementation 'com.github.pengrad:java-telegram-bot-api:4.9.0'
  8.     implementation 'com.github.kilianB:JImageHash:3.0.0'
  9.     implementation 'com.h2database:h2:1.4.200'
  10. }

Работу над индексацией я буду делать в класс ImageIndexer. В библиотеке есть класс H2DatabaseImageMatcher, который предназначен для хранения и поиска хэшей в базе данных. Сделаем метод для инициализации этой базы в зависимости от id канала.
  1. public class ImageIndexer {
  2.     private final Map<Long, H2DatabaseImageMatcher> databases = new HashMap<>(5);
  3.     private final DifferenceHash differenceHash = new DifferenceHash(32, Precision.Double);
  4.     private final PerceptiveHash perceptiveHash = new PerceptiveHash(32);
  5.  
  6.     // TODO processImage
  7.  
  8.     private H2DatabaseImageMatcher getDatabaseForChannel(Long channelId) throws SQLException {
  9.         var db = databases.get(channelId);
  10.         if (db != null) {
  11.             return db;
  12.         }
  13.         var jdbcUrl = "jdbc:h2:./imagesdb_" + channelId;
  14.         var conn = DriverManager.getConnection(jdbcUrl, "root", "");
  15.         db = new H2DatabaseImageMatcher(conn);
  16.         db.addHashingAlgorithm(differenceHash, 0.4);
  17.         db.addHashingAlgorithm(perceptiveHash, 0.2);
  18.         databases.put(channelId, db);
  19.         return db;
  20.     }
  21. }

БД сохраняется в файл с именем imagesdb_<id канала>. Самая ответственная часть заключается в этих строках:
  1. private final DifferenceHash differenceHash = new DifferenceHash(32, Precision.Double);
  2. private final PerceptiveHash perceptiveHash = new PerceptiveHash(32);
  3. // ...
  4. db.addHashingAlgorithm(differenceHash, 0.4);
  5. db.addHashingAlgorithm(perceptiveHash, 0.2);
Это выбор алгоритмов хэширования. DifferenceHash масштабирует изображение до 9x8 пикселей в оттенках серого и сравнивает разницу между соседними пикселями. PerceptiveHash тоже масштабирует изображение, но затем применяет дискретное косинусное преобразование, что позволяет находить дубликаты даже если они немного обрезаны или искажены.

Подробнее об этих и других хэшах можно почитать здесь.

В данном случае, для алгоритмов я подобрал такие параметры, которые нормально индексируют цветные фотографии и рисунки. Для чёрно-белых рисунков или комиксов придётся подбирать другие значения.

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

  1. public SimilarImagesInfo processImage(Post originalPost, BufferedImage image) throws SQLException {
  2.     final Long channelId = originalPost.getChannelId();
  3.     final String uniqueId = originalPost.getMessageId().toString();
  4.     final var db = getDatabaseForChannel(channelId);
  5.     if (db.doesEntryExist(uniqueId, differenceHash)) {
  6.         return new SimilarImagesInfo(originalPost, List.of());
  7.     }
  8.     final List<ImageResult> results = db.getMatchingImages(image)
  9.             .stream()
  10.             .map(r -> {
  11.                 final int messageId = Integer.parseInt(r.value);
  12.                 final var similarPost = new Post(channelId, messageId);
  13.                 return new ImageResult(similarPost, r.distance);
  14.             })
  15.             .filter(r -> !r.isSamePost(originalPost))
  16.             .collect(Collectors.toList());
  17.     db.addImage(uniqueId, image);
  18.     return new SimilarImagesInfo(originalPost, results);
  19. }
  20.  
  21. public class SimilarImagesInfo {
  22.     private final Post originalPost;
  23.     private final List<ImageResult> results;
  24.     // constructor, getters
  25. }
  26.  
  27. public class ImageResult {
  28.     private final Post post;
  29.     private final double distance;
  30.  
  31.     // constructor, getters
  32.  
  33.     public boolean isSamePost(Post other) {
  34.         return post.equals(other);
  35.     }
  36. }

db.getMatchingImages(image) возвращает похожие изображения. db.addImage(..) добавляет изображение в базу. В качестве ключа используется id сообщения в канале, по которому можно восстановить, к какому посту принадлежала копия изображения.


Отчёт о похожих изображениях
Остаётся связать обработчик бота с классом индексирования:
  1. // Main.java
  2. public static void main(String[] args) {
  3.     final String botToken = stringProp("BOT_TOKEN")
  4.             .orElseThrow(() -> new IllegalArgumentException("BOT_TOKEN is required"));
  5.     final ImageIndexer indexer = new ImageIndexer();
  6.     final var handler = new BotHandler(botToken, indexer);
  7.     handler.setAdminId(longProp("ADMIN_ID").orElse(0L));
  8.     handler.run();
  9. }
  10.  
  11. // BotHandler.java
  12. final var similarImagesInfos = new ArrayList<SimilarImagesInfo>();
  13. for (var post : channelPosts) {
  14.     final PhotoSize photo = getSmallestPhoto(post.photo());
  15.     try {
  16.         // download image
  17.         final SimilarImagesInfo info = indexer.processImage(originalPost, image);
  18.         if (info.hasResults()) {
  19.             similarImagesInfos.add(info);
  20.         }
  21.     } catch (IOException | SQLException e) {
  22.         System.err.format("Error while processing photo in %s%n", linkToMessage(post));
  23.     }
  24. }
  25. if (!similarImagesInfos.isEmpty()) {
  26.     sendReport(similarImagesInfos);
  27. }

Также нужно вывести отчёт и прислать его администратору бота в личку (если указан id админа). По id канала и id сообщения будет сгенерирована ссылка на пост.
  1. private void sendReport(List<SimilarImagesInfo> infos) {
  2.     String report = infos.stream().map(info -> {
  3.         String text = "For post " + formatPostLink(info.getOriginalPost()) + " found:\n";
  4.         text += info.getResults().stream()
  5.                 .map(r -> String.format("  %s, dst: %.2f", formatPostLink(r.getPost()), r.getDistance()))
  6.                 .collect(Collectors.joining("\n"));
  7.         return text;
  8.     }).collect(Collectors.joining("\n\n"));
  9.  
  10.     if (adminId == 0) {
  11.         System.out.println(report);
  12.     } else {
  13.         bot.execute(new SendMessage(adminId, report).parseMode(ParseMode.Markdown));
  14.     }
  15. }
  16.  
  17. private String formatPostLink(Post post) {
  18.     String link = linkToMessage(post.getChannelId(), post.getMessageId());
  19.     return String.format("[#%d](%s)", post.getMessageId(), link);
  20. }
  21.  
  22. private String linkToMessage(Message msg) {
  23.     return linkToMessage(msg.chat().id(), msg.messageId());
  24. }
  25.  
  26. private String linkToMessage(Long chatId, Integer messageId) {
  27.     return "https://t.me/c/" + chatId.toString().replace("-100", "") + "/" + messageId;
  28. }


Запуск
Если сейчас запустить бота, то получим ошибку:
simimgbot_1.png

Для указания токена бота нужно передать его в переменных окружения. В Intellij IDEA это делается через Run -> Edit Configurations.
simimgbot_2.png

В поле Environment variables указываем параметры, разделяя их точкой с запятой.
simimgbot_3.png

Добавляем бота в канал в качестве администратора и постим две картинки, оригинал и восьмибитную копию:
simimgbot_c1.jpg
simimgbot_c2.jpg
В личку (не забудьте начать диалог с ботом, чтобы он мог писать вам, либо не указывайте ADMIN_ID, тогда результат будет выведен в консоль) придёт сообщение о найденном дубликате.
simimgbot_r1.png

Теперь отправляем ещё одно изображение, на этот раз с изменённой яркостью и наложенным текстом:
simimgbot_c3.jpg
Получаем:
simimgbot_r2.png

Наконец, отправляем ещё две картинки, оригинал и ещё более изменённое и обрезанное изображение:
simimgbot_c1.jpg
simimgbot_c4.jpg
Бот присылает отчёт:
simimgbot_r3.png
По значению dst можно судить, насколько похожа картинка. dst 0 означает, что совпадение максимально, а dst 7 говорит о том, что были изменения.



Исходный код проекта: https://github.com/annimon-tutorials/Similar-Images-Bot
+8   8   0
568