Telegram-бот для поиска похожих изображений в канале
от aNNiMON
Наверняка многие администраторы каналов с картинками задавались вопросом уменьшения дублирования контента. Этим вопросом задался и я, так как хотелось иметь возможность удалять похожие изображения даже спустя месяцы, не прибегая к ухищрениям в виде экспорта данных канала. Поэтому я решил написать бота для этой задачи.
Алгоритм работы бота
Описанный в статье бот, будет иметь самую минимальную, но работающую реализацию.
Запущенный бот добавляется в канал в качестве администратора и с этого момента начинает индексировать все поступающие в канал картинки.
При нахождении дубликатов, администратору бота присылается отчёт с ссылками на посты копий. Так как всегда существует риск ложного срабатывания, решение об удалении картинки принимает сам администратор.
Бот может запускаться раз в час или даже раз в сутки. Как следствие, бот может быть запущен на локальном компьютере, без необходимости в сервере.
Каналов может быть сколь угодно много, для каждого будет вестись своя база данных.
В базе данных хранится хэш картинки и минимальная информация для нахождения поста в канале.
Остальное уже можно дорабатывать по своему усмотрению. Ссылка на репозиторий с исходниками в конце статьи.
Обработчик бота
Для обращения к Telegram API я буду использовать библиотеку java-telegram-bot-api.
Настройки для бота (токен бота и id администратора) будут задаваться при запуске из переменной окружения (System.getenv) или из системных параметров (System.getProperty).
Основная работа с ботом будет проходить в классе BotHandler.
В методе run() задаётся обработчик обновлений bot.setUpdatesListener. В updates содержится список из максимум 100 объектов Update. Это именно то, что нам нужно! Так как боту необязательно быстро отслеживать постинг в каналы, период обновления можно повысить (в конструкторе я задал updateListenerSleep(20_000L) 20 секунд), хотя можно запускать бота и раз в день на одну минуту, всё зависит от частоты постинга в канал.
Теперь фильтруем из всех апдейтов только картинки в канале:
Теперь в каждом объекте Message списка channelPosts гарантированно будет массив PhotoSize[], получаемый из метода msg.photo().
Для индексирования нам необязательно передавать картинку с максимально высоким разрешением, иначе это скажется на производительности. Практика показала, что даже маленького превью достаточно.
Здесь кроется возможное улучшение бота. По превью можно также находить похожие видео, анимации или документы (все медиа, у которых доступен PhotoSize getThumb()).
Минимальное изображение ищется по количеству пикселей, хотя можно и по размеру файла в байтах (ps.fileSize()).
Теперь нужно скачать файл и получить из него BufferedImage. Также для удобства указания поста в канале я создам класс, который будет хранить два значения: id канала и id сообщения:
Теперь у нас есть бот, который умеет работать только с фотографиями в канале. Он получает маленькое превью, которого уже достаточно для следующего этапа.
Индексирование изображений
Для поиска похожих изображений я воспользуюсь библиотекой JImageHash. В ней есть реализации нескольких алгоритмов хэширования, а также много понятных примеров. Обязательно загляните в репозиторий.
Работу над индексацией я буду делать в класс ImageIndexer. В библиотеке есть класс H2DatabaseImageMatcher, который предназначен для хранения и поиска хэшей в базе данных. Сделаем метод для инициализации этой базы в зависимости от id канала.
БД сохраняется в файл с именем imagesdb_<id канала>. Самая ответственная часть заключается в этих строках:
Это выбор алгоритмов хэширования. DifferenceHash масштабирует изображение до 9x8 пикселей в оттенках серого и сравнивает разницу между соседними пикселями. PerceptiveHash тоже масштабирует изображение, но затем применяет дискретное косинусное преобразование, что позволяет находить дубликаты даже если они немного обрезаны или искажены.
Подробнее об этих и других хэшах можно почитать здесь.
В данном случае, для алгоритмов я подобрал такие параметры, которые нормально индексируют цветные фотографии и рисунки. Для чёрно-белых рисунков или комиксов придётся подбирать другие значения.
Теперь нужен метод, который будет принимать картинку скачанную ботом, добавлять её в базу и выдавать результат нахождения копий.
db.getMatchingImages(image) возвращает похожие изображения. db.addImage(..) добавляет изображение в базу. В качестве ключа используется id сообщения в канале, по которому можно восстановить, к какому посту принадлежала копия изображения.
Отчёт о похожих изображениях
Остаётся связать обработчик бота с классом индексирования:
Также нужно вывести отчёт и прислать его администратору бота в личку (если указан id админа). По id канала и id сообщения будет сгенерирована ссылка на пост.
Запуск
Если сейчас запустить бота, то получим ошибку:
Для указания токена бота нужно передать его в переменных окружения. В Intellij IDEA это делается через Run -> Edit Configurations.
В поле Environment variables указываем параметры, разделяя их точкой с запятой.
Добавляем бота в канал в качестве администратора и постим две картинки, оригинал и восьмибитную копию:
В личку (не забудьте начать диалог с ботом, чтобы он мог писать вам, либо не указывайте ADMIN_ID, тогда результат будет выведен в консоль) придёт сообщение о найденном дубликате.
Теперь отправляем ещё одно изображение, на этот раз с изменённой яркостью и наложенным текстом:
Получаем:
Наконец, отправляем ещё две картинки, оригинал и ещё более изменённое и обрезанное изображение:
Бот присылает отчёт:
По значению dst можно судить, насколько похожа картинка. dst 0 означает, что совпадение максимально, а dst 7 говорит о том, что были изменения.
Исходный код проекта: https://github.com/annimon-tutorials/Similar-Images-Bot
Алгоритм работы бота
Описанный в статье бот, будет иметь самую минимальную, но работающую реализацию.
Запущенный бот добавляется в канал в качестве администратора и с этого момента начинает индексировать все поступающие в канал картинки.
При нахождении дубликатов, администратору бота присылается отчёт с ссылками на посты копий. Так как всегда существует риск ложного срабатывания, решение об удалении картинки принимает сам администратор.
Бот может запускаться раз в час или даже раз в сутки. Как следствие, бот может быть запущен на локальном компьютере, без необходимости в сервере.
Каналов может быть сколь угодно много, для каждого будет вестись своя база данных.
В базе данных хранится хэш картинки и минимальная информация для нахождения поста в канале.
Остальное уже можно дорабатывать по своему усмотрению. Ссылка на репозиторий с исходниками в конце статьи.
Обработчик бота
Для обращения к Telegram API я буду использовать библиотеку java-telegram-bot-api.
- // build.gradle
- dependencies {
- implementation 'com.github.pengrad:java-telegram-bot-api:4.9.0'
- }
- public class Main {
- public static void main(String[] args) {
- final String botToken = stringProp("BOT_TOKEN")
- .orElseThrow(() -> new IllegalArgumentException("BOT_TOKEN is required"));
- final var handler = new BotHandler(botToken);
- handler.setAdminId(longProp("ADMIN_ID").orElse(0L));
- handler.run();
- }
- private static Optional<String> stringProp(String name) {
- return Optional.ofNullable(System.getenv(name))
- .or(() -> Optional.ofNullable(System.getProperty(name)));
- }
- private static Optional<Long> longProp(String name) {
- return stringProp(name).map(Long::parseLong);
- }
- }
Настройки для бота (токен бота и id администратора) будут задаваться при запуске из переменной окружения (System.getenv) или из системных параметров (System.getProperty).
Основная работа с ботом будет проходить в классе BotHandler.
- public class BotHandler {
- private final TelegramBot bot;
- private long adminId;
- public BotHandler(String botToken) {
- bot = new TelegramBot.Builder(botToken)
- .updateListenerSleep(20_000L)
- .build();
- }
- public void setAdminId(long adminId) {
- this.adminId = adminId;
- }
- public void run() {
- bot.setUpdatesListener(updates -> {
- // TODO code
- });
- }
- }
В методе run() задаётся обработчик обновлений bot.setUpdatesListener. В updates содержится список из максимум 100 объектов Update. Это именно то, что нам нужно! Так как боту необязательно быстро отслеживать постинг в каналы, период обновления можно повысить (в конструкторе я задал updateListenerSleep(20_000L) 20 секунд), хотя можно запускать бота и раз в день на одну минуту, всё зависит от частоты постинга в канал.
Теперь фильтруем из всех апдейтов только картинки в канале:
- bot.setUpdatesListener(updates -> {
- final List<Message> channelPosts = updates.stream()
- .map(Update::channelPost)
- .filter(Objects::nonNull)
- .filter(msg -> msg.photo() != null)
- .collect(Collectors.toList());
- // ...
- });
Теперь в каждом объекте Message списка channelPosts гарантированно будет массив PhotoSize[], получаемый из метода msg.photo().
Для индексирования нам необязательно передавать картинку с максимально высоким разрешением, иначе это скажется на производительности. Практика показала, что даже маленького превью достаточно.
Здесь кроется возможное улучшение бота. По превью можно также находить похожие видео, анимации или документы (все медиа, у которых доступен PhotoSize getThumb()).
- // ... channelPosts
- for (var post : channelPosts) {
- final PhotoSize photo = getSmallestPhoto(post.photo());
- }
- // ...
- private PhotoSize getSmallestPhoto(PhotoSize[] photoSizes) {
- return Arrays.stream(photoSizes)
- .min(Comparator.comparingInt(ps -> ps.width() * ps.height()))
- .orElse(photoSizes[0]);
- }
Минимальное изображение ищется по количеству пикселей, хотя можно и по размеру файла в байтах (ps.fileSize()).
Теперь нужно скачать файл и получить из него BufferedImage. Также для удобства указания поста в канале я создам класс, который будет хранить два значения: id канала и id сообщения:
- for (var post : channelPosts) {
- final PhotoSize photo = getSmallestPhoto(post.photo());
- try {
- final var tgFile = bot.execute(new GetFile(photo.fileId())).file();
- final var url = new URL(bot.getFullFilePath(tgFile));
- final BufferedImage image = ImageIO.read(url);
- final var originalPost = new Post(post.chat().id(), post.messageId());
- // TODO image index
- } catch (IOException | SQLException e) {
- System.err.format("Error while processing photo");
- }
- }
- public class Post {
- private final Long channelId;
- private final Integer messageId;
- // constructor, getters
- // equals and hashCode
- }
Теперь у нас есть бот, который умеет работать только с фотографиями в канале. Он получает маленькое превью, которого уже достаточно для следующего этапа.
Индексирование изображений
Для поиска похожих изображений я воспользуюсь библиотекой JImageHash. В ней есть реализации нескольких алгоритмов хэширования, а также много понятных примеров. Обязательно загляните в репозиторий.
- // build.gradle
- repositories {
- jcenter()
- }
- dependencies {
- implementation 'com.github.pengrad:java-telegram-bot-api:4.9.0'
- implementation 'com.github.kilianB:JImageHash:3.0.0'
- implementation 'com.h2database:h2:1.4.200'
- }
Работу над индексацией я буду делать в класс ImageIndexer. В библиотеке есть класс H2DatabaseImageMatcher, который предназначен для хранения и поиска хэшей в базе данных. Сделаем метод для инициализации этой базы в зависимости от id канала.
- public class ImageIndexer {
- private final Map<Long, H2DatabaseImageMatcher> databases = new HashMap<>(5);
- private final DifferenceHash differenceHash = new DifferenceHash(32, Precision.Double);
- private final PerceptiveHash perceptiveHash = new PerceptiveHash(32);
- // TODO processImage
- private H2DatabaseImageMatcher getDatabaseForChannel(Long channelId) throws SQLException {
- var db = databases.get(channelId);
- if (db != null) {
- return db;
- }
- var jdbcUrl = "jdbc:h2:./imagesdb_" + channelId;
- var conn = DriverManager.getConnection(jdbcUrl, "root", "");
- db = new H2DatabaseImageMatcher(conn);
- db.addHashingAlgorithm(differenceHash, 0.4);
- db.addHashingAlgorithm(perceptiveHash, 0.2);
- databases.put(channelId, db);
- return db;
- }
- }
БД сохраняется в файл с именем imagesdb_<id канала>. Самая ответственная часть заключается в этих строках:
- private final DifferenceHash differenceHash = new DifferenceHash(32, Precision.Double);
- private final PerceptiveHash perceptiveHash = new PerceptiveHash(32);
- // ...
- db.addHashingAlgorithm(differenceHash, 0.4);
- db.addHashingAlgorithm(perceptiveHash, 0.2);
Подробнее об этих и других хэшах можно почитать здесь.
В данном случае, для алгоритмов я подобрал такие параметры, которые нормально индексируют цветные фотографии и рисунки. Для чёрно-белых рисунков или комиксов придётся подбирать другие значения.
Теперь нужен метод, который будет принимать картинку скачанную ботом, добавлять её в базу и выдавать результат нахождения копий.
- public SimilarImagesInfo processImage(Post originalPost, BufferedImage image) throws SQLException {
- final Long channelId = originalPost.getChannelId();
- final String uniqueId = originalPost.getMessageId().toString();
- final var db = getDatabaseForChannel(channelId);
- if (db.doesEntryExist(uniqueId, differenceHash)) {
- return new SimilarImagesInfo(originalPost, List.of());
- }
- final List<ImageResult> results = db.getMatchingImages(image)
- .stream()
- .map(r -> {
- final int messageId = Integer.parseInt(r.value);
- final var similarPost = new Post(channelId, messageId);
- return new ImageResult(similarPost, r.distance);
- })
- .filter(r -> !r.isSamePost(originalPost))
- .collect(Collectors.toList());
- db.addImage(uniqueId, image);
- return new SimilarImagesInfo(originalPost, results);
- }
- public class SimilarImagesInfo {
- private final Post originalPost;
- private final List<ImageResult> results;
- // constructor, getters
- }
- public class ImageResult {
- private final Post post;
- private final double distance;
- // constructor, getters
- public boolean isSamePost(Post other) {
- return post.equals(other);
- }
- }
db.getMatchingImages(image) возвращает похожие изображения. db.addImage(..) добавляет изображение в базу. В качестве ключа используется id сообщения в канале, по которому можно восстановить, к какому посту принадлежала копия изображения.
Отчёт о похожих изображениях
Остаётся связать обработчик бота с классом индексирования:
- // Main.java
- public static void main(String[] args) {
- final String botToken = stringProp("BOT_TOKEN")
- .orElseThrow(() -> new IllegalArgumentException("BOT_TOKEN is required"));
- final ImageIndexer indexer = new ImageIndexer();
- final var handler = new BotHandler(botToken, indexer);
- handler.setAdminId(longProp("ADMIN_ID").orElse(0L));
- handler.run();
- }
- // BotHandler.java
- final var similarImagesInfos = new ArrayList<SimilarImagesInfo>();
- for (var post : channelPosts) {
- final PhotoSize photo = getSmallestPhoto(post.photo());
- try {
- // download image
- final SimilarImagesInfo info = indexer.processImage(originalPost, image);
- if (info.hasResults()) {
- similarImagesInfos.add(info);
- }
- } catch (IOException | SQLException e) {
- System.err.format("Error while processing photo in %s%n", linkToMessage(post));
- }
- }
- if (!similarImagesInfos.isEmpty()) {
- sendReport(similarImagesInfos);
- }
Также нужно вывести отчёт и прислать его администратору бота в личку (если указан id админа). По id канала и id сообщения будет сгенерирована ссылка на пост.
- private void sendReport(List<SimilarImagesInfo> infos) {
- String report = infos.stream().map(info -> {
- String text = "For post " + formatPostLink(info.getOriginalPost()) + " found:\n";
- text += info.getResults().stream()
- .map(r -> String.format(" %s, dst: %.2f", formatPostLink(r.getPost()), r.getDistance()))
- .collect(Collectors.joining("\n"));
- return text;
- }).collect(Collectors.joining("\n\n"));
- if (adminId == 0) {
- System.out.println(report);
- } else {
- bot.execute(new SendMessage(adminId, report).parseMode(ParseMode.Markdown));
- }
- }
- private String formatPostLink(Post post) {
- String link = linkToMessage(post.getChannelId(), post.getMessageId());
- return String.format("[#%d](%s)", post.getMessageId(), link);
- }
- private String linkToMessage(Message msg) {
- return linkToMessage(msg.chat().id(), msg.messageId());
- }
- private String linkToMessage(Long chatId, Integer messageId) {
- return "https://t.me/c/" + chatId.toString().replace("-100", "") + "/" + messageId;
- }
Запуск
Если сейчас запустить бота, то получим ошибку:
Для указания токена бота нужно передать его в переменных окружения. В Intellij IDEA это делается через Run -> Edit Configurations.
В поле Environment variables указываем параметры, разделяя их точкой с запятой.
Добавляем бота в канал в качестве администратора и постим две картинки, оригинал и восьмибитную копию:
В личку (не забудьте начать диалог с ботом, чтобы он мог писать вам, либо не указывайте ADMIN_ID, тогда результат будет выведен в консоль) придёт сообщение о найденном дубликате.
Теперь отправляем ещё одно изображение, на этот раз с изменённой яркостью и наложенным текстом:
Получаем:
Наконец, отправляем ещё две картинки, оригинал и ещё более изменённое и обрезанное изображение:
Бот присылает отчёт:
По значению dst можно судить, насколько похожа картинка. dst 0 означает, что совпадение максимально, а dst 7 говорит о том, что были изменения.
Исходный код проекта: https://github.com/annimon-tutorials/Similar-Images-Bot