Все мы любим инструменты, которые упрощают жизнь. Gamma AI – один из них, особенно когда нужно быстро сделать презентацию. Но бесплатный сыр, как известно, бывает только в мышеловке, и в случае Gamma AI этим "сыром" становится водяной знак на PDF. Мелочь, а неприятно. Да и показать такое преподу такое себе… В общем, я решил, что с этим надо что-то делать.
Так родился Gamma AI Watermark Remover – простой инструмент, который берет PDF с водяным знаком и отдает чистый. В этой статье я расскажу, как я пришел к этому решению, какие технологии использовал, и с какими трудностями столкнулся. Будет интересно, с юмором, и надеюсь, полезно.
Постановка задачи: Проблема водяных знаков Gamma AI
Итак, водяной знак. Когда дело доходит до чего-то серьезного. Например, доклад на конференции, курсовая работа, которую нужно показать преподавателю, или, не дай бог, коммерческое предложение. Эта дьявольская штука нам очень мешает.
Конечно, можно сказать: "Ну и заплати за подписку Gamma AI, и не будет тебе никаких водяных знаков!". И это, в принципе, решение. Но, во-первых, не всегда есть бюджет на подписку, особенно если ты студент. А во-вторых… А во-вторых, где же тут спортивный интерес? Неужели мы, поколение программистов, не сможем победить какой-то там водяной знак? Это же вызов, можно сказать! К тому же, это отличная возможность попрактиковаться в программировании, покопаться в PDF-файлах, изучить новые библиотеки. Так что, поехали разбираться!
В общем, проблема водяных знаков Gamma AI – это не какая-то там глобальная катастрофа, конечно. Но это реальная проблема, которую мы и будем здесь решать. Что я, собственно, и попытался сделать.
Разработка Gamma AI Watermark Remover - история создания и "трудности на пути"
Итак, я загорелся идеей убрать водяные знаки. Логика подсказывала: надо найти в PDF водяной знак-изображение (так, как увидел как это сделал чел через Canva) и выкинуть его оттуда. Звучит просто. С чего начать? Конечно же, с библиотеки для работы с PDF на Python. Погуглив еще немного, я наткнулся на PyPDF2. "Отлично!" – подумал я. Установил, открыл документацию, начал копать.
Пробую открыть PDF, посмотреть, что там внутри. Вроде, читает текст, страницы перебирает. Начал искать, как изображения вытащить. И тут – облом. Оказывается, PyPDF2 умеет читать PDF, но не умеет его редактировать по-настоящему! Ну, то есть, какие-то метаданные поменять – это пожалуйста, а вот удалить изображение – извини-подвинься. Вот тебе и "отлично!". Потратил вечер, читая доки и пытаясь понять, как же все-таки выковырять это изображение, а оказалось, что инструмент просто не подходит для задачи. Однако, я реализовал идентификацию изображения
Думаю, думаю как понять, что изображение - это лого? Просто идентифицировать изображение по имени файла – это ненадежно. Вдруг Gamma AI поменяет имя файла водяного знака? Или, что еще хуже, будет генерировать немножко разные водяные знаки? Нужно было найти способ идентифицировать водяной знак по его виду, независимо от имени файла.
И тут я понял – гистограмма цветов! Когда читал про решение каптчи читал о ней. Что же это такое, если по-простому? Представьте себе, что вы смотрите на картинку и считаете, сколько в ней пикселей каждого оттенка серого – от черного до белого. Гистограмма – это как раз график, который показывает это распределение. Для темной картинки гистограмма будет "сдвинута" влево (к черному), для светлой – вправо (к белому). Для картинки с равномерным распределением оттенков – гистограмма будет более-менее ровной.
В чем тут фишка для водяных знаков? А в том, что водяные знаки Gamma AI, скорее всего, имеют характерную цветовую гамму и стиль. Даже если они немного меняются, их гистограммы должны быть очень похожими друг на друга. Это как отпечатки пальцев – у каждого человека свой, но у одного и того же человека отпечатки всегда будут узнаваемы. Вот и гистограмма водяного знака – это как его уникальный "отпечаток".
Чтобы проверить эту идею, я решил вручную "выковырять" изображение водяного знака из PDF-файла (тут PyPDF2 все-таки пригодился, чтобы хотя бы посмотреть структуру PDF и найти имя файла изображения водяного знака). Оказалось, что имя файла – R24.jpg. Я извлек это изображение и посчитал его гистограмму с помощью библиотеки Pillow (PIL) и NumPy. Вот код, который помог мне в этом:
from PyPDF2 import PdfReader
from PIL import Image
import io
import numpy as np
def calculate_histogram(image_data):
"""
Вычисляет гистограмму изображения в градациях серого.
"""
img = Image.open(io.BytesIO(image_data)).convert('L') # Открываем изображение из байтов и переводим в Ч/Б
hist, _ = np.histogram(img, bins=256, range=(0, 256)) # Считаем гистограмму (256 бинов - оттенков серого)
return hist / np.sum(hist) # Нормализуем гистограмму (чтобы сумма всех значений была 1)
def get_image_histogram(pdf_path, target_image_name="R24.jpg"):
"""
Извлекает гистограмму целевого изображения с первой страницы PDF (для примера).
"""
reader = PdfReader(pdf_path)
if not reader.pages:
return None, "PDF пустой."
first_page = reader.pages[0]
try:
for image_file_object in first_page.images: # Перебираем изображения на первой странице
image_name = image_file_object.name
if image_name == target_image_name: # Нашли нужное изображение (по имени!)
image_data = image_file_object.data # Получаем его байтовые данные
hist = calculate_histogram(image_data) # Считаем гистограмму
print(f"Гистограмма для '{target_image_name}' успешно вычислена.")
return hist, None
return None, f"Изображение '{target_image_name}' не найдено."
except Exception as e:
return None, f"Ошибка при обработке PDF: {str(e)}"
if __name__ == "__main__":
pdf_path = "gamma_presentation_with_watermark.pdf" # Путь к PDF-файлу (замени на свой!)
target_image = "R24.jpg" # Имя файла водяного знака (узнали из PDF структуры)
histogram, error_message = get_image_histogram(pdf_path, target_image)
if error_message:
print(f"Ошибка: {error_message}")
elif histogram is not None:
print(f"Гистограмма для '{target_image}' (первые 10 значений): {histogram[:10]}...") # Выводим для примера первые 10 значений
print("Гистограмма готова для использования в качестве 'отпечатка' водяного знака!")
else:
print(f"Не удалось получить гистограмму для '{target_image}'.")
Гистограмма для 'R24.jpg' (первые 10 значений): [0.18865122 0.01094256 0.00925421 0.01066328 0.00974929 0.00663916
0.00488734 0.00410029 0.00331323 0.00256427]
Запустив этот код, мы получаем массив чисел – гистограмму "эталонного" водяного знака. Теперь нужно было научиться сравнивать гистограммы и определять, насколько они "похожи". Ведь водяные знаки могут быть немного сжаты, изменены в размере или качестве, поэтому полное совпадение гистограмм маловероятно. Нужно было задать какой-то порог "похожести". Например, если гистограммы похожи на 80% или более, считать изображение водяным знаком. Как именно сравнивать гистограммы и какой порог выбрать – это был следующий вопрос, который предстояло решить.
Сравнение гистограмм и порог "похожести":
Есть разные способы сравнения гистограмм, но я решил использовать один из самых простых и интуитивно понятных – метод "пересечения" (intersection).
Представьте себе две гистограммы, как две кривые на графике. "Пересечение" гистограмм – это, грубо говоря, площадь, которая находится под обеими кривыми одновременно. Чем больше эта площадь, тем больше "общего" у двух гистограмм, тем они более похожи. Чтобы получить "меру похожести" в процентах, нужно разделить площадь "пересечения" на общую площадь "эталонной" гистограммы (или другой гистограммы, как вариант). Тогда мы получим число от 0 до 1 (или от 0% до 100%), где 1 (100%) означает полное совпадение гистограмм, а 0 (0%) – полное несовпадение.
Вот как выглядит код функции для сравнения гистограмм, которую я написал на Python:
import numpy as np
def compare_histograms(hist1, hist2):
"""
Сравнивает две гистограммы методом "пересечения".
Возвращает "похожесть" в диапазоне от 0 до 1.
"""
intersection = np.minimum(hist1, hist2).sum() # Находим "пересечение" - поэлементный минимум и суммируем
similarity = intersection / np.sum(hist1) # Нормализуем на сумму первой гистограммы
return similarity
В этой функции np.minimum(hist1, hist2) находит поэлементный минимум между двумя гистограммами. Это и есть "пересечение" для каждой "бин" гистограммы. Суммируя эти минимумы (.sum()), мы получаем общую "площадь пересечения". А деление на np.sum(hist1) нормализует результат, чтобы получить "похожесть" в диапазоне от 0 до 1.
Теперь, когда мы умеем сравнивать гистограммы, остается выбрать порог "похожести" (similarity threshold). Если "похожесть" гистограммы текущего изображения из PDF с "эталонной" гистограммой водяного знака превышает этот порог, мы считаем, что нашли водяной знак. Иначе – это какое-то другое изображение.
Какой порог выбрать? Честно? Я выбрал интуитивно! Слишком высокий порог – программа будет пропускать настоящие водяные знаки, если они немного отличаются от "эталона" (например, из-за сжатия). Слишком низкий порог – программа будет "цеплять" и удалять обычные изображения со слайдов, которые случайно оказались похожи на водяной знак по гистограмме (так называемые "ложные срабатывания"). И у меня в голове что-то было или 70% или 80% и я взял большее.
Выбранный порог в 0.8 (или 80%), то есть, если "похожесть" гистограмм больше или равна 0.8, изображение считается водяным знаком и подлежит удалению. И знаете что? Он оказался достаточно чувствительным для обнаружения водяных знаков Gamma AI и при этом не слишком агрессивным, чтобы случайно не удалить что-то лишнее. Конечно, этот порог можно настроить в коде программы, если кому-то захочется поэкспериментировать или подстроить под свои задачи.
Вот так, шаг за шагом, мы подошли к алгоритму идентификации водяных знаков: считаем гистограмму каждого изображения в PDF, сравниваем с "эталонной" гистограммой, и если "похожесть" выше порога – удаляем изображение. Осталось дело за малым – реализовать функцию удаления изображения из PDF с помощью другой библиотеки (немного погуглив я узнал что мне подходит PyMuPDF, ибо она давно поддерживается и недавно даже обновлялась).
"Удаление водяных знаков" и PyMuPDF!
"Другой библиотекой"? Ну, не совсем "другой". Скорее – правильной библиотекой! После мучений с PyPDF2, я, как вы помните, открыл для себя PyMuPDF (или же fitz). И оказалось, что эта библиотека – просто кладезь возможностей для работы с PDF. Она умеет не только читать PDF, но и редактировать его на лету, в том числе – удалять объекты из PDF-структуры. То, что доктор прописал!
И вот, вооружившись библиотекой, я приступил к реализации функции удаления водяных знаков. Логика простая: мы уже нашли "подозрительное" изображение (гистограмма которого похожа на "эталонную"). Теперь нужно узнать его уникальный идентификатор в PDF-файле (xref) и скомандовать PyMuPDF: "Удали-ка вот этот объект!".
Как же получить этот xref? Когда мы извлекаем изображения из страницы PDF с помощью page.get_images(full=True) в PyMuPDF, эта функция возвращает список кортежей, где каждый кортеж содержит информацию об изображении, включая его xref (cross-reference number). Вот этот xref нам и нужен! Это как паспорт изображения внутри PDF-документа.
Функция удаления изображения в PyMuPDF называется page.delete_image(xref). Все гениальное – просто! Передаешь ей xref "плохого" изображения – и оно исчезает со страницы PDF. Вот как выглядит код функции удаления водяных знаков:
import fitz # PyMuPDF
class WatermarkRemover: # Класс для "удалятеля водяных знаков" (для структуры кода)
def __init__(self):
pass # Пока ничего не делаем в конструкторе
def remove_watermarks_from_page(self, page, images_to_remove_info): # Функция для удаления с одной страницы
"""
Удаляет водяные знаки с заданной страницы PDF.
"""
removed_count = 0
for image_info in reversed(images_to_remove_info): # reversed - чтобы безопасно удалять из списка
xref = image_info['xref']
try:
page.delete_image(xref) # Удаляем изображение по xref!
removed_count += 1
print(f" Удалено изображение xref:{xref} со страницы.") # Отладочный вывод
except Exception as remove_error:
print(f" Ошибка удаления xref:{xref}: {remove_error}") # Обработка ошибок удаления
return removed_count
def process_pdf(self, pdf_path, images_to_remove_info, output_pdf_path="output_without_watermarks.pdf"): # Обработка всего PDF
"""
Обрабатывает PDF, удаляя водяные знаки, и сохраняет результат.
"""
try:
pdf_document = fitz.open(pdf_path) # Открываем PDF с помощью PyMuPDF
total_removed_count = 0
for page_num in range(pdf_document.page_count): # Перебираем страницы PDF
page = pdf_document[page_num]
page_images_to_remove = [img_info for img_info in images_to_remove_info if img_info['page'] == page_num] # Выбираем изображения для удаления с текущей страницы
removed_count = self.remove_watermarks_from_page(page, page_images_to_remove) # Удаляем водяные знаки со страницы
total_removed_count += removed_count
pdf_document.save(output_pdf_path) # Сохраняем измененный PDF
pdf_document.close() # Закрываем PDF
print(f"PDF без водяных знаков сохранен в: {output_pdf_path}") # Сообщение об успехе
print(f"Всего удалено водяных знаков: {total_removed_count}")
return output_pdf_path, None # Возвращаем путь к файлу и None (ошибки нет)
except Exception as e:
return None, f"Ошибка обработки PDF: {str(e)}" # Обработка ошибок на уровне PDF
# Пример использования (нужно добавить вызов process_pdf с нужными данными)
if __name__ == "__main__":
remover = WatermarkRemover()
pdf_path = "gamma_presentation_with_watermark.pdf" # Путь к PDF (замени на свой!)
images_to_remove_info = [ # Список "плохих" изображений (пример, нужно заполнять реальными данными детектора)
{'page': 0, 'xref': 123, 'image_name': 'R24.jpg', 'similarity': 0.95},
{'page': 1, 'xref': 456, 'image_name': 'R24.jpg', 'similarity': 0.92},
# ... и так далее, для каждой страницы и каждого водяного знака
]
output_path, error = remover.process_pdf(pdf_path, images_to_remove_info)
if error:
print(f"Ошибка: {error}")
else:
print(f"Успешно сохранено в: {output_path}")
В этом коде я создал класс WatermarkRemover для организации кода (хотя можно было и без него, для простоты). Функция remove_watermarks_from_page принимает страницу PDF и список информации об изображениях, которые нужно удалить (мы этот список получим от детектора водяных знаков). В цикле перебираем эти изображения и удаляем каждое по его xref с помощью page.delete_image(xref). Функция process_pdf обрабатывает весь PDF-документ, перебирая страницы и вызывая remove_watermarks_from_page для каждой страницы. В конце – сохраняем новый PDF-файл без водяных знаков.
Казалось бы, все готово! У нас есть детектор водяных знаков (на основе гистограмм) и удалятор водяных знаков (на основе PyMuPDF). Осталось только соединить их вместе и сделать удобный веб-интерфейс, чтобы любой желающий мог воспользоваться нашим чудо-инструментом, ну или чтобы самим было удобнее этим пользоваться. Но это – другая история.
Заключение
Вот и подошла к концу история о том, как я пытался победить водяные знаки Gamma AI. Путь оказался не то чтобы прям усыпан розами. Но, как говорится, глаза боятся, а руки делают. И в итоге, пусть и не без шероховатостей, получился вполне рабочий Gamma AI Watermark Remover.
Что мы имеем в сухом остатке? Программу на Python, которая умеет:
Брать PDF-файлы, сгенерированные Gamma AI (в бесплатной версии).
Автоматически находить и идентифицировать водяные знаки (на основе хитрой магии гистограмм).
Аккуратно удалять эти водяные знаки (благодаря мощной библиотеке PyMuPDF).
Сохранять чистенький PDF без всяких там логотипов в углу.
И все это – бесплатно, без регистрации и СМС, как говорится. Конечно, это не панацея от всех бед. Возможно, Gamma AI когда-нибудь поменяет алгоритм водяных знаков, и тогда придется что-то переделывать. Возможно, порог "похожести" в 80% не идеален для всех случаев жизни, и его нужно будет подстраивать. Но на данный момент – это работает. И, на мой взгляд, работает довольно неплохо.
Что лично мне дал этот проект? Ну, во-первых, практический опыт программирования на Python. Во-вторых, углубленное знакомство с форматом PDF и библиотеками для его обработки. В-третьих, удовлетворение от решения реальной, пусть и небольшой, проблемы. И, в-четвертых, повод написать статью для вас - Хабравчане!
Надеюсь, эта статья была вам интересна и, возможно, даже полезна. Если у вас есть какие-то вопросы, замечания, или идеи по улучшению – пишите в комментариях, буду рад обсудить. И, конечно же, ссылка на GitHub репозиторий – там вы найдете все исходники и сможете сами поковыряться, поэкспериментировать и, даже сделать что-то лучше!
Всем чистых PDF-ов и удачных презентаций!
Комментарии (6)
Quiensabe
26.01.2025 04:39В целом плюс за доведенное до рабочего инструмента решение.
Но алгоритм идентификации изображения - вызывает только ассоциации с велосипедом на костылях. Почитайте про методы нечёткого хэширования для изображений, все это давно разобрано и сделаны действительно работающие инструменты.
Сравнивать гистограммы в целом плохая идея, в них теряется почти вся информация об изображении, и то что на ваших примерах не возникло совпадений - ничего не значит. Кроме того, считать мерой степень пересечения двух гистограмм - еще хуже, ведь если на гистограммах будет большая схожая область (например похожий фоновый цвет) - это займет большую часть площади и "мелочи" уже роли играть не будут.
Например, вотермарк Gamma AI - выглядит как большая белая надпись и маленький цветной лого. Если вашей программе встретиться картинка с большой белой областью - их гистограммы могут в этой области совпасть на 90%, и логотип который занимает оставшиеся 10% - уже вообще не будет играть роли (у вас допустимый предел 80%). При этом программа просто удалит нужное изображение, и даже не сообщит, например о том, что на одной странице было удалено два "вотермарка"...
Лучше уж тогда считать площадь пересечения, а модуль площади разности гистограмм. Это даст более надежную оценку. Но тем не менее - тоже крайне ненадежную.
DedInc Автор
26.01.2025 04:39Огромное спасибо! Признаюсь, раньше не сталкивался с обработкой изображений и прочим, хотелось сделать какой-никакой инструмент. Мне скорее по большей части повезло, и то потом пришлось уточнять гистограмму и так как там обе версии изображений проверять сразу симиларити обеих гистограмм. Обязательно изучу эту тему, поищу информацию и примеры реализации.
IvanKr08
26.01.2025 04:39Был похожий случай: сайт-генератор наклеек на электрощиток. Штука очень удобная и полезная, но экспорт в PDF только с водяным знаком на весь лист, иначе просят заплатить 100 рублей или заказать печать с доставкой у них. Все бы ничего, только вот карты ЦРБ они принимать желанием не горели, а других тогда не было. Решение нашлось довольно быстро: открыть PDF в Inkscape и двумя кнопками удалить слой с водяным знаком. Не знаю, в курсе ли они про такой способ, ибо он все еще существует и не требует никаких специфичных знаний, но я удивлен, что подобная уязвимость уже в 2 сервисах, вместо простого слияния водяного знака с каким-нибудь важным элементом страницы
Talded
Как раз мучался с тем чтобы найти нужную библиотеку для PDF, вроде начинаешь юзать и также оказывается что редачить не умеет. Огромное спасибо за статью!