Работа с изображениями

Учим «Цыплёнка-помощника» брать присланное фото, что-нибудь с ним делать через Pillow и возвращать готовую картинку обратно в чат.

Pillow — это библиотека Python для работы с картинками: открыть файл, уменьшить, повернуть, наложить текст, сохранить заново. С её помощью бот превращается в маленький фоторедактор, который живёт прямо в Telegram.

В уроке про фото, файлы и стикеры наш «Цыплёнок-помощник» уже научился принимать картинки от пользователя и отправлять свои. А в уроке про запросы к API через aiohttp он ходил за данными во внешний мир. Сегодня соединяем оба навыка и добавляем третий: бот будет не просто пересылать фото, а менять его — и присылать обратно уже обработанным.

Зачем боту уметь редактировать картинки

Представь: ты ведёшь бота для своего класса. Все шлют фотки с прогулки, а бот автоматически шлёпает на каждую водяной знак «9Б, весна 2026» — и получается готовый альбом в едином стиле. Или бот для игрового клана: кидаешь свой скрин, а он делает из него квадратную аватарку нужного размера. Или мем-бот: присылаешь картинку, он добавляет рамку и подпись снизу — классический формат мема готов за секунду.

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

@chick_helper_bot
[ты прислал большое фото 4000×3000 на 5 МБ]

🐤 Готово! Уменьшил твоё фото до превью и подписал. Держи 👇
[бот присылает картинку 512×384 с подписью «🐤 codechick» в углу]

Результат: пользователь шлёт большое фото, а бот возвращает маленькую обработанную версию. Тяжёлое фото на несколько мегабайт превращается в лёгкую превьюшку, на которую ещё и нанесена подпись.

Картинка как сетка пикселей, а Pillow — её редактор

Чтобы не бояться обработки фото, держи в голове простую картину. Любое изображение — это сетка крошечных квадратиков-пикселей, как клеточки в тетради. У каждой клеточки есть свой цвет. Картинка 100×100 — это сто клеточек в ширину и сто в высоту, всего десять тысяч цветных точек. Когда мы «уменьшаем» фото, мы просто делаем эту сетку мельче; когда «поворачиваем» — раскладываем те же клеточки по-другому.

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

Кстати, теперь становится понятно, почему большое фото «весит» много мегабайт, а превью — считанные килобайты. Чем больше клеточек в сетке, тем больше цветов надо где-то записать. Фото 4000×3000 — это двенадцать миллионов пикселей, и каждый хранит свой цвет; уменьшив сетку до 512×384, мы оставляем меньше двухсот тысяч точек — отсюда и резкое падение веса. Для превьюшки в чате огромное разрешение всё равно избыточно: глаз разницы не заметит, а пересылается такая картинка почти мгновенно. Именно поэтому уменьшение — самая частая операция в фото-ботах, с неё мы и начнём.

Pillow не входит в Python по умолчанию, её надо поставить отдельно. Имя пакета при установке — Pillow (с большой буквы), а вот в коде модуль называется PIL — это историческое имя, к нему привыкаешь быстро:

pip install Pillow

Результат: в твоё виртуальное окружение установится Pillow, и в коде станет доступен импорт from PIL import Image.

Главный объект Pillow — это Image. Открыл файл — получил объект Image, поработал с ним методами, сохранил обратно в файл. Давай посмотрим на самый простой пример без всякого Telegram, чтобы прочувствовать логику.

Знакомимся с Pillow на простом примере

Представь, что у нас уже есть файл photo.jpg на диске. Откроем его, узнаем размер, уменьшим вдвое и сохраним результат в новый файл:

from PIL import Image

# 1. Открываем файл — получаем объект Image
img = Image.open("photo.jpg")
print("Размер оригинала:", img.size)   # (4000, 3000)

# 2. Считаем новый размер — в два раза меньше по каждой стороне
new_size = (img.width // 2, img.height // 2)

# 3. Уменьшаем
small = img.resize(new_size)

# 4. Сохраняем в новый файл
small.save("photo_small.jpg")

Результат: рядом с photo.jpg появится файл photo_small.jpg вдвое меньшего размера по каждой стороне. Оригинал при этом не меняется — мы создали новую картинку.

Разберём по шагам:

  • Image.open("photo.jpg") — открывает файл и возвращает объект Image. Это как «загрузить фото в редактор».
  • img.size — кортеж (ширина, высота) в пикселях. Есть и отдельные img.width и img.height.
  • img.resize((w, h)) — создаёт новую картинку нужного размера. Сам img не меняется — Pillow почти всегда возвращает новый объект, а не правит старый.
  • small.save("photo_small.jpg") — записывает картинку в файл. По расширению .jpg Pillow сам поймёт, в каком формате сохранять.

Чтобы окончательно подружиться с размерами, посчитаем новый размер на чистом Python — без картинок, просто арифметика, которую можно проверить прямо в браузере:

width, height = 4000, 3000

# хотим, чтобы большая сторона стала не больше 512
max_side = 512
ratio = max_side / max(width, height)

new_width = int(width * ratio)
new_height = int(height * ratio)

print("Было:", (width, height))
print("Стало:", (new_width, new_height))

Вывод:

Было: (4000, 3000)
Стало: (512, 384)

Видишь приём: мы считаем ratio — во сколько раз надо сжать, чтобы большая сторона стала ровно 512, — и умножаем на него обе стороны. Так картинка ужимается пропорционально, не сплющиваясь. Этот расчёт пригодится в боте.

Шаг 1–2: скачиваем фото пользователя

Теперь к боту. Первое, что нужно, — заполучить файл, который прислал пользователь, к себе на диск. В уроке про фото мы уже видели, что у фотографии есть file_id. Telegram не присылает сам файл сразу — он присылает только этот идентификатор, а скачивание мы запускаем сами. У объекта bot для этого есть удобный метод download.

Важная деталь про фото в Telegram: одно отправленное фото приходит в нескольких размерах (мелкое превью, среднее, крупное). Они лежат в списке message.photo по возрастанию размера, поэтому message.photo[-1] — это всегда самый большой, самый качественный вариант. Его и берём.

from aiogram import F
from aiogram.types import Message

@dp.message(F.photo)
async def handle_photo(message: Message):
    # берём самый крупный размер фото
    photo = message.photo[-1]

    # скачиваем файл на диск рядом с ботом
    await bot.download(photo, destination="input.jpg")

    await message.answer("🐤 Поймал твоё фото, сейчас поколдую над ним...")

Результат: как только пользователь пришлёт фото, бот скачает его в файл input.jpg рядом со своим кодом и ответит, что принял картинку в работу.

Разберём:

  • @dp.message(F.photo) — этот хэндлер срабатывает только на сообщения, в которых есть фото. F.photo — это «волшебный фильтр» aiogram: «у сообщения есть поле photo».
  • message.photo[-1] — самый большой из присланных размеров.
  • await bot.download(photo, destination="input.jpg") — скачивает файл и сохраняет под именем input.jpg. Это асинхронная операция (бот ходит в сеть за файлом), поэтому await обязателен.

Шаг 3: обрабатываем картинку через Pillow

Файл на диске — дальше работаем чистым Pillow, как в примере выше. Сделаем то, что обещали в начале: уменьшим фото до превью и нанесём подпись в углу. Для текста понадобится ещё один помощник из Pillow — ImageDraw, это как «карандаш», которым можно рисовать поверх картинки.

from PIL import Image, ImageDraw

def make_preview(src_path: str, dst_path: str):
    img = Image.open(src_path)

    # 1. Пропорционально ужимаем так, чтобы большая сторона была 512
    max_side = 512
    ratio = max_side / max(img.width, img.height)
    new_size = (int(img.width * ratio), int(img.height * ratio))
    img = img.resize(new_size)

    # 2. Берём «карандаш» и пишем подпись в левом верхнем углу
    draw = ImageDraw.Draw(img)
    draw.text((10, 10), "\ud83d\udc24 codechick", fill="white")

    # 3. Сохраняем результат
    img.save(dst_path)

Результат: функция возьмёт картинку из src_path, уменьшит её до превью и сохранит в dst_path с белой подписью «🐤 codechick» в левом верхнем углу.

Что здесь нового:

  • ImageDraw.Draw(img) — создаёт «карандаш», привязанный к нашей картинке. Всё, что мы им рисуем, ложится поверх пикселей img.
  • draw.text((10, 10), "текст", fill="white") — пишет текст. Первый аргумент — координаты (x, y), отступ от левого верхнего угла в пикселях. fill="white" — цвет букв.

Координаты в Pillow считаются от левого верхнего угла: x растёт вправо, y — вниз. То есть (10, 10) — это чуть-чуть отступить от верхнего левого угла. Если захочешь подпись внизу, надо взять y побольше, ближе к высоте картинки.

Шаг 4: отправляем результат обратно

Готовый файл лежит на диске — осталось отдать его пользователю. Отправка фото нам знакома по уроку про фото: используем message.answer_photo, а сам файл оборачиваем в FSInputFile — это способ сказать aiogram «возьми файл вот по этому пути на диске».

from aiogram.types import FSInputFile

await message.answer_photo(
    FSInputFile("output.jpg"),
    caption="🐤 Готово! Уменьшил и подписал твоё фото.",
)

Результат: бот пришлёт в чат картинку из файла output.jpg с подписью под ней.

FSInputFile расшифровывается как «File System Input File» — файл из файловой системы. Если бы картинка у нас была в памяти, а не на диске, мы бы взяли другой класс, но для нашего случая, когда мы сохранили файл через Pillow, FSInputFile — самое то.

Собираем всё в один хэндлер

А теперь — обещанный бот целиком. Соединяем все четыре шага в один хэндлер «Цыплёнка-помощника». Функцию make_preview из шага 3 считаем уже объявленной выше в файле bot.py:

from aiogram import F
from aiogram.types import Message, FSInputFile

@dp.message(F.photo)
async def handle_photo(message: Message):
    # Шаг 1–2: скачиваем самый крупный размер фото
    photo = message.photo[-1]
    await bot.download(photo, destination="input.jpg")

    await message.answer("\ud83d\udc24 Поймал фото, обрабатываю...")

    # Шаг 3: обрабатываем через Pillow
    make_preview("input.jpg", "output.jpg")

    # Шаг 4: отправляем результат обратно
    await message.answer_photo(
        FSInputFile("output.jpg"),
        caption="\ud83d\udc24 Готово! Уменьшил и подписал твоё фото.",
    )

Результат: пользователь присылает любое фото — бот скачивает его, пишет «обрабатываю...», уменьшает картинку до превью с подписью и присылает результат обратно в чат отдельным сообщением.

Вот и вся схема из начала урока, целиком на четырёх шагах:

  1. Скачали присланный файл через bot.download.
  2. Открыли его в Pillow через Image.open.
  3. Обработали: resize + подпись через ImageDraw, и save.
  4. Отправили результат обратно через answer_photo + FSInputFile.

Запомни эту четвёрку — по ней строится любой фото-бот, что бы ты с картинкой ни делал. Хочешь поворачивать? Замени тело make_preview на img.rotate(90). Хочешь делать чёрно-белым? img.convert("L"). Каркас остаётся тем же.

Обрати внимание на маленькую, но важную деталь: между скачиванием и отправкой результата бот пишет промежуточное «обрабатываю...». Это не для красоты. Скачивание файла и его обработка занимают время — пусть доли секунды, но пользователь в этот момент не понимает, увидел ли бот его фото вообще. Короткое сообщение-заглушка снимает это напряжение: человек видит, что бот не завис, а работает. В уроках про большие задачи мы ещё вернёмся к этой идее и научимся показывать статус «бот печатает...», но даже простой текст уже делает бота куда приятнее в общении.

Частые ошибки и подводные камни

1. Обрабатываешь не тот размер фото

Очень частая ловушка — взять message.photo[0] вместо message.photo[-1]. Нулевой элемент — это самое мелкое превью (часто всего 90 пикселей), и пользователь получит обратно мутный квадратик, недоумевая, куда делось его красивое фото. Помни: message.photo отсортирован от мелкого к крупному, и тебе почти всегда нужен последний — [-1].

2. Pillow не меняет картинку на месте

Новички пишут img.resize((512, 512)) отдельной строкой и ждут, что img станет меньше. Но Pillow возвращает новую картинку, а старую не трогает. Если не присвоить результат (img = img.resize(...)) или не сохранить его в новую переменную, изменение просто потеряется. Правило: результат resize, rotate, convert и подобных методов надо обязательно куда-то записать.

3. Забыл сохранить файл перед отправкой

Бывает, что всю обработку сделали в памяти, а save() вызвать забыли — и в output.jpg остаётся либо старое содержимое, либо вообще пусто. А если файл существует со вчерашнего запуска, бот вышлет позавчерашнюю картинку и будет «глючить» загадочным образом. Всегда сохраняй результат в файл перед тем, как заворачивать его в FSInputFile.

4. Pillow не установлена или импорт перепутан

Ошибка ModuleNotFoundError: No module named 'PIL' пугает новичков: «я же ставил Pillow!». Тут хитрость: ставится пакет с именем Pillow (pip install Pillow), а импортируется он как PILfrom PIL import Image. Это не опечатка, а историческая особенность. Если ошибка осталась — проверь, что ставил пакет в то же виртуальное окружение, где запускаешь бота.

5. Файлы перетирают друг друга при нескольких пользователях

Пока ты тестируешь бота в одиночку, имена input.jpg и output.jpg работают. Но если двое пришлют фото одновременно, они начнут писать в один и тот же файл и получат чужие картинки. Для надёжности делай имена уникальными — например, подставляй id пользователя: f"input_{message.from_user.id}.jpg". Тогда у каждого свой файл, и они не мешают друг другу.

Мини-практика: бот-«поворачиватель»

Теперь твой ход. Сделай «Цыплёнку-помощнику» команду-фишку: пользователь присылает фото — бот возвращает его повёрнутым на 90 градусов и подписанным «🐤 повернул для тебя».

  • Скачай присланное фото (шаг 1–2 ты уже знаешь).
  • Открой его в Pillow и вызови img.rotate(90, expand=True). Аргумент expand=True важен: без него углы картинки обрежутся, а с ним Pillow расширит холст, чтобы повёрнутое фото влезло целиком.
  • Нанеси подпись через ImageDraw и сохрани результат.
  • Отправь обратно через answer_photo + FSInputFile.

Когда заработает — попробуй усложнить. Добавь команду на выбор поворота: пусть бот сначала покажет inline-кнопки «90°», «180°», «270°» (их мы разбирали в модуле про кнопки), а после нажатия повернёт сохранённое фото на нужный угол. Подсказка: угол можно временно запомнить в FSM-состоянии пользователя, как мы делали в анкете.

Итоги

Сегодня наш «Цыплёнок-помощник» стал настоящим карманным фоторедактором. Теперь он умеет всю классическую четвёрку:

  • скачать присланное фото — bot.download(message.photo[-1], destination=...);
  • открыть и обработать через Pillow — Image.open, resize, rotate, текст через ImageDraw;
  • сохранить результат в файл — img.save(...);
  • отправить обратно — answer_photo(FSInputFile(...)).

Главная мысль урока: картинка — это просто сетка пикселей, а Pillow берёт на себя всю грязную работу с ними. Тебе остаётся выстроить четыре шага в правильном порядке — и бот превращается в фабрику мемов, аватарок или альбомов, какую только придумаешь.

В следующем уроке мы продолжим тему интеграций и научим бота работать с документами и файлами разных форматов — принимать PDF и таблицы, отдавать сгенерированные отчёты. Картинками наш «Цыплёнок» уже жонглирует, пора браться за файлы посерьёзнее. До встречи!

Проверьте себя
1. Почему для обработки берут message.photo[-1], а не message.photo[0]?
Aphoto[0] — это видео, а не фото
Bphoto[-1] — самый крупный, качественный размер фото, а photo[0] — самое мелкое превью
Cphoto[0] всегда вызывает ошибку в aiogram
DРазницы нет, можно брать любой элемент
2. Как правильно установить и импортировать Pillow?
Apip install PIL, потом from Pillow import Image
Bpip install Pillow, потом from PIL import Image
Cpip install Pillow, потом import Pillow
DPillow встроена в Python, ставить ничего не нужно
3. Что вернёт вызов img.resize((512, 512))?
AНичего — он меняет сам объект img на месте
BНовый объект Image нужного размера; исходный img при этом не меняется
CЧисло пикселей в уменьшенной картинке
DПуть к сохранённому файлу
4. Зачем при отправке готового фото оборачивать путь в FSInputFile?
AЧтобы сжать файл перед отправкой
BЧтобы сказать aiogram взять файл по указанному пути на диске и отправить его
CЧтобы зашифровать картинку
DЭто обязательно для любого текстового сообщения
5. Что задают первый аргумент (x, y) в draw.text((10, 10), ...)?
AРазмер шрифта по ширине и высоте
BКоординаты текста в пикселях от левого верхнего угла: x вправо, y вниз
CЦвет текста в формате (красный, зелёный)
DКоличество строк и столбцов текста
6. Почему имена input.jpg и output.jpg могут подвести, если боту пишут несколько человек сразу?
ATelegram запрещает повторно использовать имена файлов
BPillow не умеет работать с файлами .jpg
CРазные пользователи начнут писать в один и тот же файл и получат чужие картинки
DФайлы автоматически удаляются после первой отправки