Введение: От скрипта к полноценному приложению
Каждому Python-разработчику знакома ситуация: готов полезный скрипт, но он заперт в консоли. Как поделиться им с коллегой, далеким от терминала? Раньше это означало погружаться в дебри Tkinter, изучать монструозный PyQt или тащить тяжеловесный Electron ради пары кнопок.
К счастью, эти времена прошли. Знакомьтесь, Flet — фреймворк, который позволяет создавать современные кросс-платформенные приложения, используя только Python. В его основе лежит Flutter, но вам не придется писать ни строчки на Dart. Вы просто описываете интерфейс Python-объектами, а Flet берет на себя всю магию по его отрисовке.
В этой статье мы не будем разбирать теорию. Мы с нуля напишем полезную утилиту — мини-редактор изображений, который умеет открывать файлы, применять базовые фильтры (Ч/Б, размытие, поворот) и сохранять результат.
Вот что у нас получится в итоге:

Давайте посмотрим, как превратить простой скрипт в полноценное десктопное приложение, которое не стыдно показать другим.
Часть 1: Подготовка и создание каркаса приложения
Прежде чем погрузиться в код, давайте выполним несколько обязательных шагов, которые обеспечат чистоту и предсказуемость нашего проекта.
Шаг 0: Виртуальное окружение — цифровая гигиена
Хороший тон в Python-разработке — начинать любой проект с создания виртуального окружения. Это изолированное пространство, которое позволяет устанавливать зависимости для конкретного проекта, не засоряя глобальную установку Python и избегая конфликтов версий библиотек.
Откройте терминал в папке вашего проекта и выполните следующие команды:
Создаем окружение (назовем его
venv):python -m venv venvАктивируем его:
Windows (Command Prompt / PowerShell):
venv\Scripts\activatemacOS / Linux:
source venv/bin/activate
После активации вы увидите (venv) в начале строки вашего терминала. Это значит, что мы готовы к работе.
Шаг 1: Установка зависимостей
Нашему приложению понадобятся всего две библиотеки: flet для создания интерфейса и pillow для всех манипуляций с изображениями. Установим их одной командой:
pip install flet pillow
Вот и вся подготовка. Просто, не так ли?
Шаг 2: Базовая структура Flet-приложения
Теперь создадим наш главный Python-файл, например, main.py. Любое Flet-приложение имеет простую и понятную структуру:
Импортируем библиотеку, обычно как
ft.Создаем функцию
main, которая принимает один аргумент —page. Этот объектpageявляется нашим главным окном или холстом, куда мы будем добавлять все элементы.Запускаем приложение с помощью
ft.app().
Давайте напишем минимальный код, чтобы убедиться, что все работает:
# main.py import flet as ft def main(page: ft.Page): # Настраиваем окно приложения page.title = "Flet Image Editor" page.window_width = 600 page.window_height = 800 page.vertical_alignment = ft.MainAxisAlignment.CENTER page.horizontal_alignment = ft.CrossAxisAlignment.CENTER # Добавляем на страницу простой текст page.add( ft.Text("Каркас нашего приложения готов!", size=20) ) # Запускаем приложение, указывая нашу main-функцию как цель ft.app(target=main)
Запустите этот файл (python main.py), и вы должны увидеть окно с заголовком и приветственным текстом. Отлично, мы на верном пути!
Шаг 3: Проектирование и создание каркаса интерфейса
Теперь самое интересное — спроектируем наш интерфейс. Мы хотим разместить элементы вертикально, один под другим. Для этого идеально подходит виджет ft.Column. Внутри него мы расположим горизонтальные ряды (ft.Row) с кнопками и основной виджет для отображения картинки (ft.Image).
Давайте заменим наш приветственный текст на реальные, но пока нефункциональные элементы управления.
# main.py import flet as ft def main(page: ft.Page): page.title = "Flet Image Editor" page.window_width = 600 page.window_height = 800 # Выравниваем все по центру page.vertical_alignment = ft.MainAxisAlignment.CENTER page.horizontal_alignment = ft.CrossAxisAlignment.CENTER # --- Виджеты нашего приложения --- # Кнопки для работы с файлами open_button = ft.ElevatedButton("Открыть") save_button = ft.ElevatedButton("Сохранить", disabled=True) # Кнопка неактивна, пока нет изображения # Изображение, которое мы будем редактировать # Изначально оно невидимо image_view = ft.Image( visible=False, width=500, height=500, fit=ft.ImageFit.CONTAIN, ) # Кнопки фильтров filters_row = ft.Row( controls=[ ft.ElevatedButton("Ч/Б"), ft.ElevatedButton("Размытие"), ft.ElevatedButton("Поворот"), ], visible=False, # Панель фильтров тоже скрыта alignment=ft.MainAxisAlignment.CENTER, ) # --- Собираем интерфейс --- # Добавляем все виджеты на страницу в одну колонку page.add( ft.Row([open_button, save_button], alignment=ft.MainAxisAlignment.CENTER), image_view, filters_row ) ft.app(target=main)
Запустите код еще раз. Теперь у вас есть окно с кнопками "Открыть" и неактивной "Сохранить". Изображение и фильтры пока скрыты — мы покажем их, как только пользователь выберет файл.

Наш каркас готов! Мы создали структуру приложения и разместили все необходимые элементы.
Часть 2: Открываем и отображаем изображение
Чтобы приложение могло "общаться" с файловой системой компьютера — открывать стандартные диалоговые окна "Выбор файла" или "Сохранить как" — Flet предоставляет специальный виджет-помощник: ft.FilePicker.
Шаг 1: Добавляем FilePicker на страницу
В отличие от кнопок и полей, FilePicker — это невидимый компонент. Он живет в "слое оверлеев" (page.overlay) и ждет, пока его вызовут.
Давайте создадим экземпляр FilePicker и добавим его на нашу страницу. Также нам нужно сразу "подписать" его на событие on_result — это событие сработает, когда пользователь выберет файл и закроет диалоговое окно.
Добавьте этот код в функцию main, перед тем как мы собираем интерфейс.
# ... внутри def main(page: ft.Page): # --- Диалоговое окно для выбора файла --- def on_file_picker_result(e: ft.FilePickerResultEvent): # Эта функция будет вызвана, когда пользователь выберет файл # Пока оставим её пустой print("Выбран файл:", e.files) file_picker = ft.FilePicker(on_result=on_file_picker_result) # Добавляем FilePicker в оверлей страницы, чтобы он был доступен page.overlay.append(file_picker) # --- Виджеты нашего приложения --- # ... (остальной код виджетов)
Мы создали сам FilePicker и функцию on_file_picker_result, которая будет обрабатывать его результат.
Шаг 2: Оживляем кнопку "Открыть"
Теперь нам нужно связать нашу кнопку "Открыть" с FilePicker. Делается это очень просто: по нажатию на кнопку мы должны вызывать метод pick_files() у нашего file_picker. Этот метод и откроет системное диалоговое окно.
Мы можем добавить обработчик on_click прямо при создании кнопки.
# Заменим создание кнопки open_button open_button = ft.ElevatedButton( "Открыть", on_click=lambda _: file_picker.pick_files( allow_multiple=False, # Запрещаем выбор нескольких файлов allowed_extensions=["jpg", "jpeg", "png", "bmp"], # Фильтруем типы файлов dialog_title="Выберите изображение" ) )
Мы использовали lambda-функцию для краткости. Теперь при нажатии на кнопку "Открыть" Flet покажет окно выбора файла, причем в нем будут видны только изображения указанных форматов.
Ша-г 3: Обрабатываем выбор пользователя и отображаем картинку
Самая важная часть. Когда пользователь выберет файл, сработает событие on_result, и Flet вызовет нашу функцию on_file_picker_result. Внутри этой функции нам нужно:
Проверить, что пользователь действительно выбрал файл, а не закрыл окно.
Получить путь к выбранному файлу.
Установить этот путь как источник (
src) для нашего виджетаft.Image.Сделать видимыми само изображение и панель с фильтрами.
Активировать кнопку "Сохранить".
Вызвать
page.update(), чтобы все эти изменения отобразились на экране.
Давайте наполним нашу функцию on_file_picker_result логикой:
# Заполняем нашу функцию-обработчик def on_file_picker_result(e: ft.FilePickerResultEvent): # Если пользователь отменил выбор, e.files будет None if e.files: # Получаем путь к первому выбранному файлу file_path = e.files[0].path # Обновляем источник изображения image_view.src = file_path image_view.visible = True # Делаем видимой панель фильтров и активной кнопку "Сохранить" filters_row.visible = True save_button.disabled = False # Обязательно обновляем страницу, чтобы применить изменения page.update()
Вот и все! Давайте посмотрим на полный код main.py после всех изменений.
# main.py import flet as ft def main(page: ft.Page): page.title = "Flet Image Editor" page.window_width = 600 page.window_height = 800 page.vertical_alignment = ft.MainAxisAlignment.CENTER page.horizontal_alignment = ft.CrossAxisAlignment.CENTER def on_file_picker_result(e: ft.FilePickerResultEvent): if e.files: file_path = e.files[0].path image_view.src = file_path image_view.visible = True filters_row.visible = True save_button.disabled = False page.update() file_picker = ft.FilePicker(on_result=on_file_picker_result) page.overlay.append(file_picker) open_button = ft.ElevatedButton( "Открыть", on_click=lambda _: file_picker.pick_files( allow_multiple=False, allowed_extensions=["jpg", "jpeg", "png", "bmp"], dialog_title="Выберите изображение" ) ) save_button = ft.ElevatedButton("Сохранить", disabled=True) image_view = ft.Image( visible=False, width=500, height=500, fit=ft.ImageFit.CONTAIN, ) filters_row = ft.Row( controls=[ ft.ElevatedButton("Ч/Б"), ft.ElevatedButton("Размытие"), ft.ElevatedButton("Поворот"), ], visible=False, alignment=ft.MainAxisAlignment.CENTER, ) page.add( ft.Row([open_button, save_button], alignment=ft.MainAxisAlignment.CENTER), image_view, filters_row ) ft.app(target=main)
Запустите скрипт. Теперь кнопка "Открыть" активна. Нажмите на нее, выберите любое изображение на вашем компьютере, и оно тут же появится в окне приложения вместе с панелью фильтров.

Наше приложение стало интерактивным!
Часть 3: "Швейцарский нож" в действии — применяем фильтры
Сейчас наши кнопки "Ч/Б", "Размытие" и "Поворот" — просто элементы интерфейса. Наша задача — связать их с реальными операциями по обработке изображений. Flet будет отвечать за нажатия кнопок, а всю тяжелую работу по пиксельным манипуляциям возьмет на себя Pillow.
Шаг 1: Состояние приложения — где хранить изображение?
Когда пользователь применяет фильтр, мы не можем просто изменить исходный файл на диске. Нам нужно работать с копией изображения в памяти. Давайте заведем переменную, в которой будем хранить текущее состояние изображения в виде объекта Pillow.
Добавьте в начало функции main импорты из Pillow и io (он понадобится нам для работы с данными в памяти), а также создайте переменную-хранилище current_image.
# main.py import flet as ft from PIL import Image, ImageFilter # Импортируем Image и ImageFilter import io # Импортируем io import base64 # Импортируем base64 def main(page: ft.Page): # ... настройки страницы ... # Переменная для хранения текущего изображения в формате Pillow current_image: Image = None # ... остальной код ...
Теперь, когда пользователь выбирает файл, нам нужно не только показать его, но и загрузить в нашу переменную current_image. Обновим функцию on_file_picker_result:
# Обновляем on_file_picker_result def on_file_picker_result(e: ft.FilePickerResultEvent): nonlocal current_image # Указываем, что будем изменять внешнюю переменную if e.files: file_path = e.files[0].path # Загружаем изображение в Pillow и сохраняем его current_image = Image.open(file_path) image_view.src = file_path image_view.visible = True filters_row.visible = True save_button.disabled = False page.update()
Шаг 2: Ключевая магия — как показать измененное изображение
Вот тут и кроется главный технический момент. Когда мы применим фильтр, у нас будет измененный объект Image от Pillow в памяти. Но как его показать в виджете ft.Image? Ведь его свойство src ожидает путь к файлу.
Решение: свойство src_base64. Оно позволяет загрузить изображение напрямую из строки, закодированной в формате Base64. Наш план:
Взять измененный объект Pillow
Image."Сохранить" его не на диск, а в специальный буфер в оперативной памяти (
io.BytesIO).Получить из буфера байты изображения.
Закодировать эти байты в строку Base64.
Передать эту строку в
image_view.src_base64.
Давайте напишем для этого небольшую вспомогательную функцию, чтобы не дублировать код.
# Добавим эту функцию внутрь main def update_image_view(img_obj: Image): """Конвертирует объект Pillow в base64 и обновляет виджет.""" buffer = io.BytesIO() img_obj.save(buffer, format="PNG") # Сохраняем в буфер в формате PNG base64_img = base64.b64encode(buffer.getvalue()).decode("utf-8") image_view.src_base64 = base64_img page.update()
Шаг 3: Реализуем фильтры
Теперь, когда у нас есть все необходимое, написать сами обработчики фильтров — одно удовольствие. Для каждой кнопки создадим свою функцию.
Начнем с черно-белого фильтра:
# Функция для Ч/Б фильтра def apply_bw_filter(e): nonlocal current_image if current_image: # Применяем фильтр bw_image = current_image.convert("L") # Обновляем текущее изображение current_image = bw_image # Обновляем виджет на странице update_image_view(current_image)
Размытие (Blur):
# Функция для размытия def apply_blur_filter(e): nonlocal current_image if current_image: blurred_image = current_image.filter(ImageFilter.BLUR) current_image = blurred_image update_image_view(current_image)
Поворот:
# Функция для поворота def apply_rotate_filter(e): nonlocal current_image if current_image: # Поворачиваем на 90 градусов против часовой стрелки rotated_image = current_image.rotate(90, expand=True) current_image = rotated_image update_image_view(current_image)
Осталось только привязать эти функции к on_click наших кнопок. Найдите место, где создается filters_row, и обновите его:
# Обновляем filters_row filters_row = ft.Row( controls=[ ft.ElevatedButton("Ч/Б", on_click=apply_bw_filter), ft.ElevatedButton("Размытие", on_click=apply_blur_filter), ft.ElevatedButton("Поворот", on_click=apply_rotate_filter), ], visible=False, alignment=ft.MainAxisAlignment.CENTER, )
Готово! Запустите приложение, откройте изображение и попробуйте нажать на кнопки фильтров. Вы увидите, как картинка мгновенно меняется. Причем фильтры применяются последовательно: вы можете сделать изображение черно-белым, а затем повернуть его.
Часть 4: Сохранение результата и обратная связь
Сейчас кнопка "Сохранить" активна, но безжизненна. Мы это исправим. Вся логика у нас уже есть: измененное изображение хранится в переменной current_image. Нам лишь нужно спросить у пользователя, куда его сохранить.
Шаг 1: Настраиваем сохранение файла
Для сохранения мы снова воспользуемся FilePicker, но на этот раз вызовем у него метод save_file(). Чтобы не усложнять логику нашего единственного обработчика (on_file_picker_result), который сейчас заточен под открытие файлов, лучшей практикой будет создать отдельный FilePicker специально для сохранения.
Создадим новый
FilePickerи его функцию-обработчикon_save_file_result.Добавим его также в
page.overlay.Привяжем его вызов к кнопке "Сохранить".
# Добавляем этот код внутрь main, рядом с первым пикером # --- Диалоговое окно для СОХРАНЕНИЯ файла --- def on_save_file_result(e: ft.FilePickerResultEvent): # Логика сохранения будет здесь pass save_file_picker = ft.FilePicker(on_result=on_save_file_result) page.overlay.append(save_file_picker) # ... # Обновляем создание кнопки "Сохранить" save_button = ft.ElevatedButton( "Сохранить", disabled=True, on_click=lambda _: save_file_picker.save_file( dialog_title="Сохранить как...", file_name="edited_image.png", # Имя файла по умолчанию allowed_extensions=["png", "jpg", "bmp"] ) )
Шаг 2: Реализуем логику сохранения и добавляем обратную связь
Теперь наполним нашу новую функцию on_save_file_result. Она должна:
Проверить, что пользователь указал путь для сохранения.
Вызвать метод
save()у нашего объектаcurrent_imageиз Pillow.Показать пользователю уведомление, что все прошло успешно.
Для уведомлений в Flet есть замечательный виджет — ft.SnackBar. Он появляется внизу экрана и через несколько секунд исчезает.
Давайте допишем финальный код.
# Добавляем создание SnackBar в начало функции main def main(page: ft.Page): # ... # Создаем SnackBar для уведомлений page.snack_bar = ft.SnackBar(content=ft.Text("Сообщение!")) # ... # Теперь заполняем функцию on_save_file_result def on_save_file_result(e: ft.FilePickerResultEvent): # Если пользователь выбрал путь для сохранения if e.path: try: current_image.save(e.path) # Показываем уведомление об успехе page.snack_bar.content = ft.Text(f"Изображение успешно сохранено в {e.path}") page.snack_bar.open = True page.update() except Exception as ex: # Показываем уведомление об ошибке page.snack_bar.content = ft.Text(f"Ошибка при сохранении: {ex}") page.snack_bar.open = True page.update()
Мы обернули сохранение в try...except, чтобы поймать возможные ошибки (например, нет прав на запись) и вежливо сообщить о них пользователю.
Поздравляю! Наше приложение полностью функционально. Мы прошли весь путь от пустого файла до полноценного десктопного редактора изображений. Мы научились работать с файловой системой, обрабатывать события, интегрировать мощные библиотеки и предоставлять пользователю качественную обратную связь.
▶️ Полный код main.py
import flet as ft from PIL import Image, ImageFilter import io import base64 def main(page: ft.Page): # --- НАСТРОЙКИ ОКНА --- page.title = "Flet Image Editor" page.window_width = 600 page.window_height = 800 page.vertical_alignment = ft.MainAxisAlignment.CENTER page.horizontal_alignment = ft.CrossAxisAlignment.CENTER page.snack_bar = ft.SnackBar(content=ft.Text("Сообщение!")) # --- ПЕРЕМЕННЫЕ СОСТОЯНИЯ --- current_image: Image = None # --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- def update_image_view(img_obj: Image): """Конвертирует объект Pillow в base64 и обновляет виджет.""" buffer = io.BytesIO() img_obj.save(buffer, format="PNG") base64_img = base64.b64encode(buffer.getvalue()).decode("utf-8") image_view.src_base64 = base64_img page.update() # --- ОБРАБОТЧИКИ СОБЫТИЙ --- def on_file_picker_result(e: ft.FilePickerResultEvent): nonlocal current_image if e.files: file_path = e.files[0].path current_image = Image.open(file_path) image_view.src = file_path image_view.visible = True filters_row.visible = True save_button.disabled = False page.update() def on_save_file_result(e: ft.FilePickerResultEvent): if e.path and current_image: try: current_image.save(e.path) page.snack_bar.content = ft.Text(f"Изображение успешно сохранено в {e.path}") page.snack_bar.open = True page.update() except Exception as ex: page.snack_bar.content = ft.Text(f"Ошибка при сохранении: {ex}") page.snack_bar.open = True page.update() def apply_bw_filter(e): nonlocal current_image if current_image: current_image = current_image.convert("L") update_image_view(current_image) def apply_blur_filter(e): nonlocal current_image if current_image: current_image = current_image.filter(ImageFilter.BLUR) update_image_view(current_image) def apply_rotate_filter(e): nonlocal current_image if current_image: current_image = current_image.rotate(90, expand=True) update_image_view(current_image) # --- FILE PICKERS --- open_file_picker = ft.FilePicker(on_result=on_file_picker_result) save_file_picker = ft.FilePicker(on_result=on_save_file_result) page.overlay.extend([open_file_picker, save_file_picker]) # --- ВИДЖЕТЫ ИНТЕРФЕЙСА --- open_button = ft.ElevatedButton( "Открыть", on_click=lambda _: open_file_picker.pick_files( allow_multiple=False, allowed_extensions=["jpg", "jpeg", "png", "bmp"], dialog_title="Выберите изображение" ) ) save_button = ft.ElevatedButton( "Сохранить", disabled=True, on_click=lambda _: save_file_picker.save_file( dialog_title="Сохранить как...", file_name="edited_image.png", allowed_extensions=["png", "jpg", "bmp"] ) ) image_view = ft.Image( visible=False, width=500, height=500, fit=ft.ImageFit.CONTAIN, ) filters_row = ft.Row( controls=[ ft.ElevatedButton("Ч/Б", on_click=apply_bw_filter), ft.ElevatedButton("Размытие", on_click=apply_blur_filter), ft.ElevatedButton("Поворот", on_click=apply_rotate_filter), ], visible=False, alignment=ft.MainAxisAlignment.CENTER, ) # --- СБОРКА ИНТЕРФЕЙСА --- page.add( ft.Row([open_button, save_button], alignment=ft.MainAxisAlignment.CENTER), image_view, filters_row ) # --- ЗАПУСК ПРИЛОЖЕНИЯ --- ft.app(target=main)
Домашнее задание: Что можно улучшить?
Поздравляю, у вас есть рабочее приложение! Но, как и любой хороший проект, его всегда можно сделать еще лучше. Эти задания помогут вам глубже понять Flet и добавить в редактор профессиональные функции. Раскройте каждое задание, чтобы увидеть условие.
1. Регулировка силы эффекта с помощью слайдера
Задача: Сейчас фильтр "Размытие" применяетcя с фиксированной силой. Замените его на ft.Slider, который позволит пользователю плавно регулировать степень размытия от 0 до 10.
Подсказки:
Добавьте виджет
ft.Sliderвfilters_row. Установите ему минимальное (min=0) и максимальное (max=10) значения.Вместо события
on_clickу кнопки используйте событиеon_changeу слайдера. Ваша функция-обработчик будет получать значение слайдера.В Pillow для регулируемого размытия используйте не
ImageFilter.BLUR, аImageFilter.GaussianBlur(radius=value), гдеvalue— это значение, пришедшее от слайдера.Не забудьте, что применять фильтр нужно к исходному изображению, а не к уже размытому, иначе эффект будет накапливаться с каждым движением слайдера.
2. Кнопка "Сброс" для отмены всех изменений
Задача: Пользователь может применить несколько фильтров подряд, но у него нет способа вернуться к оригиналу, кроме как открыть файл заново. Добавьте кнопку "Сброс", которая отменяет все примененные эффекты.
Подсказки:
Вам понадобится еще одна переменная состояния, помимо
current_image. Назовите ее, например,original_image.В функции
on_file_picker_result(при открытии файла) сохраняйте объект Pillow и вoriginal_image, и вcurrent_image.Функция-обработчик для кнопки "Сброс" должна будет просто скопировать объект из
original_imageвcurrent_imageи обновитьimage_view.
3. Добавление новых фильтров
Задача: Расширьте функциональность редактора, добавив как минимум два новых фильтра из библиотеки Pillow.
Подсказки:
Добавьте новые виджеты
ft.ElevatedButtonвfilters_row.Напишите для них новые функции-обработчики по аналогии с существующими.
Изучите документацию
ImageFilterв Pillow. Попробуйте реализовать, например,ImageFilter.CONTOUR(выделение контуров) илиImageFilter.SHARPEN(увеличение резкости). Они применяются так же просто, как и размытие.
4. Отображение информации об изображении
Задача: Сделайте приложение более информативным, добавив под изображением текстовую строку, которая показывает его размеры и формат. Например: "Размер: 1920x1080 | Формат: PNG".
Подсказки:
Добавьте виджет
ft.Textв основнойColumnвашего приложения, подimage_view. Изначально он может быть пустым или скрытым.В функции
on_file_picker_result, после загрузки изображения вcurrent_image, вы можете получить его свойства:current_image.size(это кортеж, например(1920, 1080)) иcurrent_image.format(это строка, например"PNG").Сформируйте нужную строку и установите ее как значение (
value) для вашегоft.Textвиджета, после чего обновите страницу.
Анонс новых статей, полезные материалы, а так же если в процессе решения возникнут сложности, обсудить их или задать вопрос по статье можно в моём Telegram-сообществе.
Уверен, у вас все получится. Вперед, к практике!
