Обработка изображений с помощью библиотеки Pillow в Python Python

Обработка изображений с помощью библиотеки Pillow в Python

Вряд ли кто-то из нас – Python разработчиков не возжелал бы прихвастнуть среди коллег или при устройстве на работу наличием в своем портфолио крутого ПО, работающего с графикой и не уступающего по возможностям (хотя бы частично) супернавороченным редакторам, подобным PhotoShop. Вместе с тем желание на профессиональном уровне работать с растровой графикой в создаваемых нами Python программах, отнюдь не является чем-то сюрреалистичным и неосуществимым. Скорее, наоборот, это желание на Пайтон довольно легко воплотить в жизнь даже тем из нас, кто еще не является профессионалом в программировании. О том, как это сделать наилучшим образом и пойдет речь в этой статье.

Когда мы окидываем какое-либо изображение собственным взором, то обычно различаем на нем совокупность составляющих его элементов – людей, животных, различных предметов и т.д. Но, если мы смотрим на картинку с точки зрения программ, разработанных, с помощью Python или любого другого языка, то в любом изображении видим всего лишь примитивный массив чисел. Однако, благодаря разработанным для различных языков программирования внешним библиотекам (модулям), определенным образом, обрабатывающим этот самый массив чисел, мы с вами, как программисты можем совершать над изображениями просто невероятные вещи. Данная статья, как раз и посвящена описанию возможностей одной из таких Python библиотек Pillow, предназначаемой для работы с растровыми изображениями, представляемыми с помощью все того же вышеупомянутого массива чисел.

Pillow и его предшественник PIL являются лишь частными оригинальными библиотеками Python в целой чреде, имеющихся на сегодняшний день у данного интерпретатора, модулей для работы с растровой графикой. Однако, несмотря на многочисленные альтернативы, вышеназванная библиотека Pillow остается среди разработчиков чрезвычайно популярным инструментарием, который позволяет на примере своих возможностей воочию постичь все основные принципы работы с растровыми изображениями.

Для обработки и модификации изображений Pillow предоставляет инструменты, абсолютно аналогичные тем, которые присущи всем растровым редакторам подобным Photoshop. Вместе с тем, следует отметить, что некоторые, появившееся в последнее время библиотеки по обработки изображений в Python, по сути являются расширением Pillow и, следовательно имеют более широкие возможности и продвинутую функциональность.

Окунувшись в чтение этой статьи мы с вами узнаем, как:

  • Читать изображения с помощью классов и методов Pillow
  • Выполнять основные операции с изображениями
  • Использовать Pillow для обработки изображений
  • Применять NymPy вместе с Pillow для углубленной обработки изображений
  • Создавать анимацию посредством использования Pillow

Также в рамках этой статье сделан краткий обзор тех примеров обработки изображений, которые мы с вами можем легко реализовать благодаря использованию некоторых, наиболее популярных методов работы с растровой графикой. Это, в свою очередь, придаст нам уверенность в применении базовых приемов работы с изображениями, а также позволит максимально эффективно приступит к использованию непосредственной документации по Pillow* для изучения остальных, более продвинутых возможностей этой библиотеки.

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

Примечание: В ходе изучения данной статьи нам с вами предстоит познакомится с массой практических примеров, работающих с теми или иными  уже готовыми растровыми изображениями. Поэтому, все эти изображения нам нужно будет предварительно скачать на компьютер в виде Архива исходных растров, который затем потребуется еще и распаковать, как подкаталог original-pictures в тот наш рабочий каталог проекта, где в последующем мы будем выполнять все практические примеры нашей статьи.

Основные операции с изображениями в библиотеке Python Pillow

Библиотека Pillow представляет собой продолжение более старой библиотеки PIL (Python Imaging Library), являющейся, в свою очередь, оригинальной Python библиотекой, предназначаемой для работы с растровыми изображениями. Использование PIL было прекращено в 2011 году, поскольку поддерживалось только 2-ой версией языка Python. При появлении же Pillow разработчики стали рассматривать эту библиотеку, как более удобное ответвление PIL, которое сохраняет все функции старой библиотеки, но включает поддержку Python 3-ей версии.

Как уже упоминалось выше, под Python на сегодняшней день разработан целый ряд библиотек, специализирующихся на самой разнообразной обработке растровых изображений. Так, например, при необходимости непосредственного манипулирования пикселями растровых изображений в Python мы можем воспользоваться такими библиотеками, как NumPy и SciPy. К другим популярным модулям по работе с изображениями можно отнести OpenCV, scikit-image, Mahotas и иные библиотеки, отдельные из которых на сегодняшний день являются уже более быстрыми и более функциональными, нежели Pillow.

Однако библиотека Pillow и до сих пор еще остается чрезвычайно важным и популярным инструментом среди разработчиков для работы с изображениями. Одной из причин столь сильной популярности этой библиотеки является достаточно большая схожесть основных ее функций по обработке изображений с подобными же функциями присущими для таких графических редакторов, как Adobe Photoshop. Зачастую, именно поэтому, наш выбор падает на Pillow, где процессы обработки изображений очень сильно сопряжены с аналогичными процессами уровня графических редакторов, которые в свою очередь не требуют слишком продвинутых навыков в сфере манипулирования растровыми изображениями. В сущности, по этой же причине легкости освоения возможностей библиотеки по аналогии с графическими редакторами, Pillow получила широчайшее распространение и в различных научно-исследовательских сферах среди ученых, занимающихся программированием на любительских началах.

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

Теперь же, после того как мы немножко познакомились с общим положением вещей вокруг библиотеке Pillow, настало время установить эту библиотеку с тем, чтобы далее ее использовать в наших практических примерах. С этой целью, нам сначала нужно установить виртуальную среду, а затем воспользовавшись системой управления пакетами pip, непосредственно установить Pillow в вышеназванную виртуальную среду. Все эти действия в зависимости от установленной у нас операционной системы, производятся следующим образом:

# Для Windows с использованием командной оболочки PowerShell
PS> python -m venv venv
PS> .\venv\Scripts\activate
(venv) PS> python -m pip install Pillow
 
# Для Linux подобных операционных систем с использованием командной оболочки Shell
$ python -m venv venv
$ source venv/bin/activate
(venv) $ python -m pip install Pillow

Базовые действия, совершаемые над изображениями

Основным предусматриваемым в библиотеке Pillow классом является класс Image, за счет которого, в частности, происходит считывание изображения и его сохранение в соответствующих объектах вышеназванного класса Image.

Давайте попробуем воспользоваться этим классом и загрузить файл изображения с именем zdaniya.jpg, находящейся у нас в подкаталоге original-pictures нашего рабочего каталога с проектом:

Примечание: Далее нам с вами предстоит познакомится с массой практических примеров, работающих с теми или иными  уже готовыми растровыми изображениями, которые из предварительно скаченного Архива исходных растров, нужно будет распаковать в подкаталог original-pictures, находящемся в рабочем каталоге нашего проекта.

Непосредственно загрузить вышеназванное изображение в оперативную память нашего компьютера, а также посмотреть параметры используемые для этого класса и объекта, можно при помощи следующего кода:

from PIL import Image
 
filename = "original-pictures/zdaniya.jpg"
with Image.open(filename) as img:
    img.load()
   
    print(type(img))
    # <class 'PIL.JpegImagePlugin.JpegImageFile'>
 
    print(isinstance(img, Image.Image))
    # True

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

Из вышеприведенного кода не очень понятно, почему мы импортируем PIL, а не Pillow и, вообще, откуда появилась данная библиотека, ведь мы ее не устанавливали. Дело в том, что Pillow – это ответвление библиотеки PIL и, именно поэтому, при импорте в нашем коде используется вышеназванная библиотека PIL.

Также в нашем коде для чтения изображения из файла мы вызываем функцию open()и метод .load(), который осуществляет загрузку изображения в память из предварительно созданного в операторе with объекта img. Все вышеназванные операции за счет все того же оператора with формируются в так называемую конструкцию диспетчера контекста, позволяющею автоматически закрывать те файлы, которые в программах более нам не нужны.

По результатам вывода функции type() в вышеприведенном примере, мы видим, что объект img представляет собой изображение в формате JPEG, характеризующееся, как подкласс класса Image, что также подтверждается и вызовом функции isinstance(). Здесь, в рамках рассмотрения результата функции type(), также следует обратить внимание на то, что и класс, и модуль, в котором определен класс, имеют одно и то же имя – Image.

Отобразить, загруженное в память, изображение на экране мы можем с помощью метода .show():

from PIL import Image
 
filename = "original-pictures/zdaniya.jpg"
with Image.open(filename) as img:
    img.load()
    img.show()

Вышеназванный метод .show() сохраняет изображение как временный файл и отображает его, используя соответствующее программное обеспечение, предусматриваемое нашей операционной системой для работы с графикой.

Итак, запустив вышеприведенный код с методом .show(), мы наконец-то увидим нашу 1-ю картинку:

image

Примечание: В некоторых случаях вызов .show() блокирует интерактивные среды выполнения типа REPL до тех пор, пока мы не закроем отображаемую картинку. Возникновение подобных ситуаций может зависеть, как от нашей операционной системы, так и от, запускаемой по умолчанию на нашем компьютере, программы для просмотра изображений.

Прежде всего, при работе с библиотекой Pillow нам необходимо понимать значение и научится работать с тремя ключевыми свойствами объектов, порождаемых классом Image.  Доступ ко всем этим трем свойствам вышеназванного класса можно получить через его соответствующие одноименные атрибуты, как .format, .size и .mode:

from PIL import Image
 
filename = "original-pictures/zdaniya.jpg"
with Image.open(filename) as img:
    img.load()
 
    print(img.format)
    # 'JPEG'
 
    print(img.size)
    # (1920, 1273)
 
    print(img.mode)
    # 'RGB'

Свойство Format показывает, с каким форматом изображения мы имеем дело. Так, нашей картинке соответствует формат 'JPEG'. Свойство Size предоставляет данные о, измеряемых в пикселях, ширине и высоте заданной картинки. Свойство же Mode представляет собой сведения о режимах вывода (цветовых моделях) анализируемого изображения (для нашей картинке – это аддитивная цветовая модель 'RGB'), которые более подробно будут рассмотрены далее в этой статье.

Зачастую, нам также приходится обрезать и изменять размер растровых изображений. Для реализации подобных операций в классе Image из нашей библиотеки Pillow предусматриваются два соответствующие методы: .crop() и .resize():

# Использование метода .crop() для обрезки растрового изображения
 
from PIL import Image
 
filename = "original-pictures/zdaniya.jpg"
with Image.open(filename) as img:
    img.load()
 
    obrezka_img = img.crop((300, 150, 700, 1000))
 
    print(obrezka_img.size)
    # (400, 850)
 
    obrezka_img.show()
    
# Использование метода .resize() для изменения размера растрового изображения
 
from PIL import Image
 
filename = "original-pictures/zdaniya.jpg"
with Image.open(filename) as img:
    img.load()
 
    obrezka_img = img.crop((300, 150, 700, 1000))
    umensheniye_img = obrezka_img.resize((obrezka_img.width // 4,
                                          obrezka_img.height // 4))
 
    print(umensheniye_img.size)
    # (100, 212)
 
    umensheniye_img.show()

Метод .crop() в качестве аргумента предусматривает прием четырехэлементного кортежа, определяющего соответственно левый, верхний, правый и нижний края области, которая должна будет остаться после нашей обрезки. Тут, при указании элементов вышеназванного кортежа нужно исходить из используемой в Pillow системы координат, предусматривающей расположение начального пикселя в верхнем левом углу с координатами (0, 0). Кстати, точно такая же система координат обычно используется и для двумерных массивов.  В результате обрезки, заданной параметрами нашего четырёхэлементного кортежа, мы получим следующую секцию изображения:

image

Результирующее изображения после применения метода .crop() из вышеприведенного примера по размерам обрежется до 400x850 пикселей, а на непосредственно самом этом обрезанном изображении будет видно только одно из зданий с исходной картинки:

image

В вышеприведенном коде мы также с помощью метода .resize() дополнительно еще и изменяем размер уже обрезанного изображения. Такое изменение размера производится путем задания вышеназванному методу через принимаемый им в качестве аргумента двухэлементный кортеж новых значений ширины и высоты результирующего изображения.

Например, в рассматриваемом примере мы устанавливаем новую ширину и высоту обрезанного изображения, равными одной четвертой их исходных значений за счет секционного оператора деления (//), используемого при расчете соответствующих значений на основе параметров width и height в двухэлементном кортеже. Наконец, вызов метода .show()* в последней строке нашего последнего примера приводит к отображению обрезанного изображения с измененными размерами:

image 

Кроме новых значений ширины и высоты в двухэлементном кортеже, метод .resize() принимает еще и ряд необязательных параметров, которые, в частности, задают либо метод интерполяции при изменениях размеров изображений, либо область внутри изображения, подлежащую непосредственному изменению. Альтернативой методу .resize(), с помощью которого можно добиться аналогичного масштабирования изображений, является метод .reduce():

umensheniye_img = obrezka_img.reduce(4)

Аргументом вышеприведённого метода .reduce() является коэффициент, уменьшающий масштаб изображения. Кроме того, для изменения размеров изображения в библиотеке Pillow есть еще один весьма своеобразный метод .thumbnail(), который выполняет изменение размеров изображения в рамках следующих требований:

  1. сохранение соотношения сторон;
  2. не превышение максимальных размеров исходного изображения;
  3. не превышение размеров, указываемых в аргументах метода thumbnail().

Таким образом, результирующий размер изображения после его обработки методом thumbnail() будет всегда меньшим или же равным, тому размеру, который мы задавали в аргументе этого метода с помощью двухэлементного массива.

Примечание: Для учета определенных нюансов программирования, нам важно запомнить, что результатом выполнения тех или иных методов класса Image в библиотеке Pillow может быть либо изменение Image объекта на месте, как в случае с методом .thumbnail(), либо создание нового Image объекта, как в случае с методами .crop(), .resize() и .reduce().

Как только нас устроит программно-измененное изображение, мы можем через его Image объект сохранить это изображение в файл, используя метод .save(). Давайте попробуем это сделать, предварительно создав в рабочем каталоге нашего проекта подкаталог resulting-images, куда будем сохранять все свои изображения, созданные по результатам выполнения дальнейших практических примеров этой статьи:

from PIL import Image
 
filename = "original-pictures/zdaniya.jpg"
with Image.open(filename) as img:
    img.load()
 
    obrezka_img = img.crop((300, 150, 700, 1000))
    umensheniye_img = obrezka_img.resize((obrezka_img.width // 4,
                                          obrezka_img.height // 4))
 
    obrezka_img.save("resulting-images/obrezka.jpg")
    umensheniye_img.save("resulting-images/obrezka_umensheniye.png")

После вызова из вышеприведенного кода соответствующих методов .save(), у нас в подкаталоге resulting-images сохранится файл obrezka.jpg и файл obrezka_umensheniye.png. Эти два файла, в частности, различаются своими расширениями (jpg и  png), исходя из которых метод .save() производит автоматическое сохранение информации для данных файлов в соответствующих форматах JPEG и PNG. Помимо неявного указания форматов файлов исходя из их расширения, метод .save() позволяет также прямо задавать нужные нам форматы для сохраняемых изображений путем их указания в качестве своих дополнительных (необязательных) аргументов.

Изменение ориентации у изображений

Помимо обрезки и изменения размера, также весьма распространёнными возможностями преобразования изображений в библиотеке Pillow является смена ориентации изображения за счет его поворота или отражения. Все эти действия можно выполнять за счет довольно мощного метода класса Image под названием .transpose(). Давайте посмотрим, как он работает на примере следующего кода:

from PIL import Image
 
filename = "original-pictures/zdaniya.jpg"
with Image.open(filename) as img:
    img.load()
 
    preobrazovannoye_img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
    preobrazovannoye_img.show()

Благодаря этому коду наша картинка со зданиями переворачивается так, как показано на следующем изображение:

image

С помощью метода .transpose() можно осуществлять трансформацию наших изображений посредством применения в качестве его аргументов семи следующих опций:

  • Image.Transpose.FLIP_LEFT_RIGHT – переворачивает изображение слева направо, в результате чего получается его зеркальное отображение.
  • Image.Transpose.FLIP_TOP_BOTTOM – переворачивает изображение сверху вниз.
  • Image.Transpose.ROTATE_90 – осуществляет поворот изображения на 90 градусов против часовой стрелки.
  • Image.Transpose.ROTATE_180 – поворачивает изображение на 180 градусов.
  • Image.Transpose.ROTATE_270 – осуществляет поворот изображения на 270 градусов против часовой стрелки, что соответствует повороту на 90 градусов по часовой стрелке.
  • Image.Transpose.TRANSPOSE – транспонирует (меняет местами) горизонтальные и вертикальные пиксели изображения, используя при этом верхний левый пиксель в качестве стартовой, но не затрагиваемой транспонированием точки в результирующим изображении.
  • Image.Transpose.TRANSVERSE – транспонирует (меняет местами) горизонтальные и вертикальные пиксели изображения, используя при этом нижний левый пиксель в качестве стартовой, но не затрагиваемой транспонированием точки в результирующим изображении.

Все перечисленные выше опции поворота изображения в методе .transpose() выполняются с шагом в 90 градусов. Поэтому, если нам нужно повернуть нашу картинку на иной (произвольный) угол, то придется воспользоваться методом .rotate():

from PIL import Image
 
filename = "original-pictures/zdaniya.jpg"
with Image.open(filename) as img:
    img.load()
 
    povernutoye_img = img.rotate(45)
    povernutoye_img.show()

Данный пример использования метода .rotate() поворачивает картинку на 45 градусов против часовой стрелки, формируя следующее изображение:

image

Как можно заметить, особенностью данного изображение является то, что вследствие сохранения размеров своего исходного прародителя, в этом изображении теряются его углы. Поэтому, для того чтобы избежать данной проблемы, в качестве дополнительного аргумента метода .rotate() нам можно воспользоваться поименованным параметром expand:

    povernutoye_img = img.rotate(45, expand=True)
    povernutoye_img.show()

Благодаря вышеназванному параметру expand полотно, не занятое вследствие поворота самым, обрабатываемым изображением увеличивается таким образом, чтобы углы данного изображения не обрезались. В результате ширина и высота результирующего изображения всегда будет большей, чем у его исходного прародителя:

image

Вышеназванный метод .rotate() содержит также еще и ряд дополнительных аргументов, более подробную информацию о которых мы с вами можем найти в статье Как повернуть изображение используя Pillow.

Итак, мы с вами уже научились изменять размер и ориентацию изображения. В следующем же разделе нашей статьи мы узнаем о возможностях библиотеки Pillow при работе с режимами вывода изображений.

Работа с режимами вывода изображений

Как уже было сказано раннее, изображение представляет собой двумерный массив пикселей, где каждому пикселю соответствует определенный цвет. Но, помимо этого, каждый пиксель в изображении может иметь одно или несколько свойств (характеристик). Например, в цветном изображении RGB каждый пиксель представлен тремя свойствами, предопределяющими уровни насыщенности этого пикселя соответственно красным, зеленым и синем цветом.

Таким образом, цвет Image объекта, порождённого одноименным классом библиотеки Pillow для RBG-изображения формируется тремя свойствами (каналами), каждому из которых соответствует тот или иной цвет. Следовательно, к примеру, RGB-изображение, имеющее размер 100x100 пикселей на самом деле всегда будет ассоциировано с 3-мерным массивом размером 100x100 значений.

Изображение же, характеризующееся, как RGBA предусматривает еще и включение для каждого пикселя дополнительного свойства – так называемого альфа-канала, содержащего информацию о прозрачности этого пикселя. Таким образом, изображение RGBA имеет уже четыре свойства - по одному для каждого из цветов и четвертый, содержащий альфа-значение, регулирующее прозрачность пикселя. Причем, каждое такое свойство в отношении всего изображения имеет тот же размер, что и само это изображение в пикселях. Следовательно, по аналогии с RGB, RGBA-изображение, имеющее размер 100x100 пикселей всегда будет ассоциироваться с 4-мерным массивом размером 100x100 значений.

В терминологии библиотеки Pillow, также как и в наиболее популярных графических редакторах, стандартные цветовые модели и режимы вывода изображений объединены под единым термином режимы. Исходя из этого, под поддерживаемыми режимами в Pillow подразумевается вывод изображений, как в виде дуплекса (черно-белого вывода) или градаций серого, так и на основе стандартных цветовых моделей типа RGB, RGBA и CMYK. При необходимости, с полным списком поддерживаемых в Pillow режимов всегда можно ознакомится в соответствующем разделе англоязычной документации по данной библиотеке.

Для определения перечня свойств, присущих тому или иному Image объекту нам придется воспользоваться методом .getbands(), а с целью осуществления конвертации между режимами этого объекта мы можем использовать метод .convert(). Давайте попробуем применить все эти методы к изображению klubnika.jpg, которое из соответствующего архива должно быть у нас сохранено в подкаталоге original-pictures, находящемся в рабочем каталоге нашего проекта:

image

Вышеприведенное изображение изначально сформировано в режиме RGB. Однако, преобразовать этот режим изображения в любой другой при помощи библиотеки Pillow мы можем легко и быстро, воспользовавшись всего лишь одной строчкой кода. И именно это подтверждает нижеследующий пример:

from PIL import Image
 
filename = "original-pictures/klubnika.jpg"
with Image.open(filename) as img:
    img.load()
 
    # Преобразование в режим вывода изображения на основе цветовой модели "CMYK"
    cmyk_img = img.convert("CMYK")
    # Преобразование в режим вывода изображения в градациях серого
    gray_img = img.convert("L")
 
    cmyk_img.show()
    gray_img.show()
 
    print(img.getbands())
    # ('R', 'G', 'B')
 
    print(cmyk_img.getbands())
    # ('C', 'M', 'Y', 'K')
 
    print(gray_img.getbands())
    # ('L',)

В приведенном выше примере мы вызываем метод .convert() один раз для преобразования изображения из RGB в CMYK, а второй раз для преобразования все того же RGB изображения в режим с градациями серого. Изображение в CMYK почти не отличается от нашего исходного RGB изображения, однако, имея определенные специфические свойства, данный режим применяется сугубо для распечатки полиграфической продукции и, мало пригоден для компьютерных дисплеев. Преобразование же в режим с градациями серого, дает следующий результат:

image

Последние строки раннее приведенного кода с вызовами метода getbands() для каждого из имеющихся Image объектов наглядно подтверждают наличие 3-х свойств у изображения со цветовой моделью RGB, четырех свойств у изображения с цветовой моделью CMYK, и одного свойства у изображения с градациями серого.

В арсенале библиотеки Pillow есть также возможность, как разделения изображений по их свойствам с помощью метода .split(), так и объединения отдельных свойств изображений обратно в Image объект посредством глобального метода класса Image Image.merge(). При использовании метода .split(), он возвращает каждое свойство исходного объекта в виде отдельных одноканальных Image объектов. Давайте попробуем убедиться в этом, отобразив строковое представление одного из одноканальных объектов, возвращаемых методом .split():

from PIL import Image
 
filename = "original-pictures/klubnika.jpg"
with Image.open(filename) as img:
    img.load()
 
    red, green, blue = img.split()
    print(red)
    # <PIL.Image.Image image mode=L size=1920x1281 at 0x17A132161D0>
 
    print(red.mode)
    # L

В рамках данного кода, при анализе Image объекта, возвращенного методом .split() в переменную red, мы определяем, что данный объект имеет свойство L, предопределяющее то, что он является одноканальным изображением, у которого каждый пиксель характеризуется лишь уровнем яркости красного цвета.

Используя глобальный метод класса Image Image.merge() мы можем из предварительно разделенных одноканальных Image объектов создать три новых RGB-изображения, показывающих красный, зеленый и синий каналы по отдельности:

from PIL import Image
 
filename = "original-pictures/klubnika.jpg"
with Image.open(filename) as img:
    img.load()
 
    red, green, blue = img.split()
 
    pustyye_pikseli = red.point(lambda _: 0)
    krasnoye_sliyaniye = Image.merge("RGB", (red, pustyye_pikseli,
                                             pustyye_pikseli))
    zelenoye_sliyaniye = Image.merge("RGB", (pustyye_pikseli, green,
                                             pustyye_pikseli))
    sineye_sliyaniye = Image.merge("RGB", (pustyye_pikseli, pustyye_pikseli,
                                           blue))
 
    krasnoye_sliyaniye.show()
    zelenoye_sliyaniye.show()
    sineye_sliyaniye.show()

Первый аргумент метода Image.merge() определяет режим изображения, которое мы хотим создать. Последующие же аргументы данного метода содержат отдельные свойства, которые мы хотим объединить в одно изображение.

Например, для того чтобы создать RGB изображение, имеющее исключительно только один красный канал, мы должны в качестве основы применить предварительно полученный от метода split() одноканальный Image объект в градациях красного цвета, сохраненный в переменной red. Этот red объект в методе Image.merge() нам нужно будет объединить с аналогичными одноканальными Image объектами, имеющими нулевые значения для зеленого и синего цветов. В свою очередь, для создания таких Image объектов с нулевыми значениями (пустыми пикселями) нам придется воспользоваться методом .point(), принимающим в качестве своего аргумента функцию, задающую алгоритм преобразования каждой точки в изображении. В нашем случае, такой функцией является lambda, сопоставляющая каждый пиксель нашего изображения с нулем.

Таким образом, в результате объединения одноканального Image объекта в градациях красного с обнуленными одноканальными Image объектами для зеленого и синего цветов, мы получаем объект с RGB изображением, присваивающийся переменной krasnoye_sliyaniye. Конечно же, этот, созданный нами объект будет иметь ненулевые значения только лишь в красном канале, но, поскольку метод Image.merge() совершил его объединение в RGB режиме, то ассоциирующееся с ним трехканальное изображение в любом программном обеспечении должно будет рассматриваться, как цветное.

Аналогично изображению, сохранённому в переменной krasnoye_sliyaniye в вышеприведенном коде, мы получаем также и трехканальные RGB изображения в градациях одного лишь зеленого или синего, сохраненные соответственно в переменных zelenoye_sliyaniye и sineye_sliyaniye. Все эти три результирующие изображения, выводящиеся на экран с помощью метода  show(), приведены ниже:

image

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

Обработка изображений с помощью Pillow в Python

Из предыдущих разделов этой статьи мы с вами узнали, как обрезать и поворачивать изображения, изменять их размер и извлекать одноканальные (однотонные) изображения из их полноцветных оригиналов. Однако ни одно из перечисленных выше действий не предусматривало внесения каких-либо изменений в содержимое изображения. В этом же разделе статьи мы с вами узнаем о возможностях библиотеки Python Pillow в сфере обработки (модификации) изображений и научимся использовать из этой библиотеки такой мощный модуль, как ImageFilter.

Фильтры изображений на основе ядер свертки

Одним из методов, применяемых при обработке изображений, является так называемая свертка изображений, подразумевающая вычисление новых значений для выбранных нами пикселей исходя из значений тех пикселей, которые их окружают. В свою очередь, та матрица (площадь) пикселей, которая используется для вычисления новых значений выбранных нами пикселей, ассоциируется с термином ядро свертки. Хотя далее мы с вами более подробно углубимся в тонкости работы с ядрами свертки, однако следует отметить, что цель этой статьи заключается вовсе не в предоставлении подробных разъяснений по теории обработки изображений. Поэтому, при необходимости более глубокого изучения этой действительно крайне интересной, обширной и немножечко сложной темы в сфере обработки изображений, нам с вами, к примеру, можно будет обратиться к одному из лучших ресурсов в данной области – Digital Image Processing от Gonzalez and Woods.

В этом же разделе мы рассмотрим только лишь азы применения ядра свертки для обработки изображений. Но, прежде, давайте попробуем все-таки более подробно разобраться, как же работает это ядро свертки в принципе. Так, мы с вами уже знаем, что данное ядро представляет собой матрицу:

image

Теперь, чтобы понять процесс свертки с использованием ядер, давайте рассмотрим простое изображение, которое имеет размер 30x30 пикселей, а также содержит вертикальную линию и точку. При этом, пусть линия в этом изображении имеет ширину четыре пикселя, а точка состоит из квадрата 4x4 пикселя. Пример изображения, приведенный ниже, для наглядности является увеличенной версией оригинала:

image

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

image

Размещенные на этой диаграмме элементы характеризуют различные аспекты процесса свертки изображения и имеют следующее предназначение:

  • Белые квадраты – представляют пиксели изображения с нулевым значением (в оригинале эти пиксели имеют черный цвет).
  • Красные квадраты – изображают пиксели со значением 255 (в оригинале – белый цвет). Именно эти пиксели составляют белую точку на оригинальном изображении выше.
  • Каждая фиолетовая область – представляет собой то самое, рассматриваемое нами ядро свертки, состоящее из матрицы размером 3х3. Размер этой матрицы предопределяет то, что коэффициент присущий каждой ячейки в этом ядре имеет значение 1/9. Для нашего случае, на диаграмме ядро показано в трех различных положениях, обозначающихся цифрами 1, 2 и 3.

Теперь, для того чтобы глубже разобраться в процессе свертки изображения и создания из него новой картинки (например, с эффектом размытия), давайте рассмотрим с вами соответствующий алгоритм, состоящий из следующих шагов:

  1. Выбор ядра – выбираем месторасположение очередного ядра в виде матрицы 3х3 и анализируем значения пикселей, которые попали в эту матрицу.
  2. Умножение – умножаем значение пикселей по каждой ячейки матрицы ядра на соответствующий коэффициент ячеек, который в нашем случае согласно размеру этой матрицы равен 1/9. В результате у нас должно получиться 9-ть новых значений для каждой ячейки матрицы ядра.
  3. Сложение: слаживаем все эти девять новых значений, рассчитанных на предыдущем шаге, и заносим полученную сумму, как значение пикселя для центральной ячейки матрицы нашего ядра. Таким образом, результирующее значение вышеназванного пикселя будет соответствовать значению пикселя с теми же координатами в новом изображении. 4.Повторение: повторяем шаги 1 – 3 для каждого пикселя изображения, перемещая при этом матрицу ядра таким образом, чтобы центральная ее ячейка соответствовала бы очередному пикселю изображения.

Используя данный алгоритм, давайте с вами более детально рассмотрим процесс свертки изображения на примере тех трех позиций матрицы ядра, соответственно обозначаемых номерами 1, 2 и 3, которые у нас были указаны в раннее приведенной диаграмме.

Для начала в этом аспекте попробуем рассмотреть матрицу ядра под номером 1, у которой ее центральная ячейка находится на четвертой строке (индекс = 3) третьего столбца (индекс = 2) и, соответственно имеет координаты (3, 2). Особенностью матрицы данного ядра является то, что каждый пиксель в области этой матрицы является нулевым и, соответственно имеет черный цвет. Следовательно, все те умножения и сложения, которые нам нужно было бы осуществить согласно 2-му и 3-му шагам вышеприведенного алгоритма, приведут к тому, что нулевое значение центрального пикселя матрицы этого ядра так и останется нулевым. Таким образом, пиксель в координатах (3, 2) для нового изображения по прежнему будет иметь нулевое значение.

Для остальных же позиций матрицы ядра в нашей диаграмме, сценарий свертки изображения будет несколько более сложным. Так для второй позиции матрицы ядра с центральным пикселем в координатах (4,7) характерной особенностью является то, что один пиксель в этой области матрицы является белым и имеет значение 255.  Поэтому, после осуществления 2-го и 3-го шагов приведенного выше алгоритма в отношении данной матрицы, мы для пикселя с координатами (4,7) в новом изображении будем иметь уже не нулевое значение, а значение равное 255 х (1/9) = 28,33. Данное значение именно для матрицы ядра со 2-й позиции обусловлено тем, что для остальных ее восьми пикселей характерны нулевые значения, которые никак не смогут отразится на сложении в третьем шаге алгоритма.

Для третьей же позиции матрицы ядра с центральным пикселем в координатах (8, 11), характерной чертой является присутствие в этой области аж четырех ненулевых пикселей, каждый из которых имеет значение 255. Поэтому, исходя из аналогий со второй матрицей ядра, тут для расчета значения пикселя в координатах (8, 11) для нового изображения мы должны использовать следующее выражение: 255 х (1/9) х 4 = 113,33.

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

На изображении ниже, справа показан результат полноценной свертки, а слева для наглядности показано исходное изображение:

image

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

По аналогии с графическими редакторами, библиотека Pillow также имеет ряд встроенных фильтров и методов, выполняющих вышеописанные модификации (свертки) изображений. При этом, для применения данных возможностей Pillow, нам отнюдь не обязательно вникать в технологию производимой фильтрации или хотя бы понимать используемые в ней общие математические подходы для той или иной модификации изображений. Вместе с тем, нам никогда не помещает, хотя бы в общих чертах ознакомиться с тем, что же все-таки происходит за кулисами процесса с таким загадочным названием, как свертка изображения. Именно этим, мы как раз и занимались в этом подразделе нашей статьи.

В следующих же разделах этой статьи нами будут рассмотрены различные возможности фильтрации изображений, доступные в ImageFilter модуле библиотеки Pillow.

Размытие, резкость и сглаживание изображения

Давайте опять вернемся к уже знакомому нам по первым практическим примерам в статье, изображению со зданиями, которое снова загрузим в память нашего компьютера, но уже с предварительно импортированным модулем ImageFilter из библиотеки Pillow:

from PIL import Image, ImageFilter
 
filename = "original-pictures/zdaniya.jpg"
with Image.open(filename) as img:
    img.load()

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

Благодаря дополнительно импортированному в вышеприведенном коде модулю ImageFilter у нас появляется возможность использования метода .filter(), позволяющего совершать различные модификации изображения. Данный метод в качестве своего аргумента должен принимать один из стандартных фильтров (алгоритмов модификации изображений, называемых еще ядрами свертки), предусмотренных в модуле ImageFilter. Для начала, давайте с вами рассмотрим набор фильтров, специализирующихся на размытии, повышении резкости и на сглаживании изображений.

Итак, сперва посмотрим, как можно размыть изображение с помощью такого, предустановленного в модуле ImageFilter фильтра, как BLUR:

    blur_img = img.filter(ImageFilter.BLUR)
    blur_img.show()

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

    blur_img = img.filter(ImageFilter.BLUR)
    img.crop((300, 300, 500, 500)).show()
    blur_img.crop((300, 300, 500, 500)).show()

Таким образом, две сопоставленные версии обрезанных изображений, приведенные на картинке ниже, наглядно показывают разницу между собой:

image

Воспользовавшись дополнительными фильтрами ImageFilter.BoxBlur() или ImageFilter.GaussianBlur(), мы можем также настроить тип и степень необходимого нам размытия в изображении:

    img.filter(ImageFilter.BoxBlur(5)).show()
    img.filter(ImageFilter.BoxBlur(20)).show()
    img.filter(ImageFilter.GaussianBlur(20)).show()

В результате мы получим три размытых изображения, расположенные в том же порядке, что и в приведенном выше примере кода:

image

Алгоритм применения фильтра ImageFilter.BoxBlur() абсолютно аналогичен алгоритму описанному раннее в разделе этой статьи, посвящённому теории свертки изображений. Данный фильтр в качестве своего аргумента принимает так называемый радиус размытия прямоугольной области, являющейся по сути аналогом матрицы ядра, которую мы уже рассматривали в упоминаемом выше разделе. Как мы помним, в этом разделе была рассмотрена матрица ядра размерностью 3x3 пикселя. Такая ее размерность, в свою очередь, предполагает, что область данной матрицы должна простираться на один пиксель во все стороны от пикселя, расположенного в ее центре. Следовательно, радиус размытия для матрицы ядра размерностью3x3 всегда должен быть равен 1-му.

Фактически представленное выше разъяснение сути радиуса размытия полностью соответствует вышеприведенным примерам изображений, показывающим, что фильтр размытия прямоугольной области с радиусом 20 создает более размытое изображение, чем изображение, сгенерированное подобным же фильтром, но с радиусом 5.

Упоминаемый выше фильтр ImageFilter.GaussianBlur() отличается от .BoxBlur() тем, что он основан на применении алгоритма размытия по методу  Гаусса. Данный алгоритм придает больший вес пикселям в центре ядра, нежели пикселям по краям, что приводит к более плавному размытию, в отличие от размытия методом .BoxBlur(). По этой причине, в большинстве случаев размытие по Гауссу является более предпочтительным и, фактически, наиболее распространенным способом вышеназванной модификации изображений.

Не менее востребованным чем фильтры размытия, является и фильтр ImageFilter.SHARPEN, применяющейся для повышения резкости изображений. Давайте применим данный фильтр к изображению со зданиями, а затем снова для наглядности выведем на экран обрезанные варианты исходного изображения и того, к которому был применен вышеназванный фильтр:

    rezkoye_img = img.filter(ImageFilter.SHARPEN)
    img.crop((300, 300, 500, 500)).show()
    rezkoye_img.crop((300, 300, 500, 500)).show()

Ниже приведен результат выполнения данного кода в виде двух обрезанных изображений, к последнему из которых был применен фильтр ImageFilter.SHARPEN:

image

Зачастую, вместо повышения резкости изображения, возникает необходимость в применении противоположного фильтра с целью сглаживания этого изображения. Для этого в качестве аргумента к методу .filter() мы можем применить такой фильтр, как ImageFilter.SMOOTH:

    sglazhennyy_img = img.filter(ImageFilter.SMOOTH)
    img.crop((300, 300, 500, 500)).show()
    sglazhennyy_img.crop((300, 300, 500, 500)).show()

В результате выполнения вышеприведенного кода на экран выводятся два изображения, левое из которых является исходным, а правое - сглаженным:

image

Фильтр для сглаживания изображений нами также будет применен и в следующем подразделе этой статьи, посвященном рассмотрению ряда дополнительных фильтров из модуля ImageFilter воздействующих на края объектов, отображаемых на картинках.

Определение и улучшение границ объектов, а также тиснение

Любое изображение мы всегда можем представить, как совокупность определенных объектов, каждый из которых зрительно можно легко обособить, определив его края (границы). Абсолютно те же действия можно произвести и программным путем с помощью специального алгоритма, позволяющего обнаруживать границы объектов на изображениях за счет соответствующего фильтра обнаружения границ.

Естественно, таким фильтром априори просто не может не обладать и модуль ImageFilter из библиотеке Pillow. В этом подразделе статьи мы с вами как раз и рассмотрим работу фильтра по обнаружению границ на примере все того же изображения со зданиями, вывод которого предварительно нужно будет перевести в режим отображения с градациями серого:

    img_seryy = img.convert("L")
    kraya = img_seryy.filter(ImageFilter.FIND_EDGES)
    kraya.show()

Результатом работы фильтра, используемого в этом коде, будет своеобразное граффити - картинка, показывающая границы объектов из исходного изображения.

image

Как мы уже убедились, предназначение фильтра ImageFilter.FIND_EDGES заключается в распознавании и наведении (акцентировании) границ объектов на изображениях. Полученное изображение в результате применения данного фильтра довольно привлекательно, но все же его не помешало бы улучшить. Для этого к изображению в градациях серого, перед применением фильтра обнаружения границ, мы можем дополнительно применить фильтр mageFilter.SMOOTH для предварительного сглаживания данного изображения:

    img_seryy = img.convert("L")
    img_seroye_sglazhennoye = img_seryy.filter(ImageFilter.SMOOTH)
    kraya_sglazhennyye = img_seroye_sglazhennoye.filter(ImageFilter.FIND_EDGES)
    kraya_sglazhennyye.show()

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

image

Кроме фильтра обнаружения границ еще одним фильтром, работающим с границами объектов в изображениях, является, включенный в модуль ImageFilter фильтр .EDGE_ENHANCE, позволяющий усилить и улучшить границы (контуры) вышеназванных объектов:

    img_seryy = img.convert("L")
    img_seroye_sglazhennoye = img_seryy.filter(ImageFilter.SMOOTH)
    edge_enhance = img_seroye_sglazhennoye.filter(ImageFilter.EDGE_ENHANCE)
    edge_enhance.show()

Для получение более качественной картинки с применением фильтра ImageFilter.EDGE_ENHANCE мы использовали сглаженную версию исходного изображения в градациях серого. Ниже для наглядности приведено два обрезанных изображения в градациях серого, первое из которых является исходным изображением, а второе – результатом применения к исходному изображению фильтра для усиления и улучшения контуров (границ) его объектов:

image

В модуле ImageFilter есть еще один встроенный фильтр .EMBOSS, работающий с границами объектов и позволяющий осуществить тиснение изображения. Подобно другим фильтрам, описанным в этом подразделе статьи, мы можем применить ImageFilter.EMBOSS в качестве аргумента метода .filter():

    img_seryy = img.convert("L")
    img_seroye_sglazhennoye = img_seryy.filter(ImageFilter.SMOOTH)
    emboss = img_seroye_sglazhennoye.filter(ImageFilter.EMBOSS)
    emboss.show()

По аналогии с предыдущим кодом, тут в качестве исходного изображения для фильтра тиснения, мы используем сглаженную версию исходного изображения в градациях серого. Ниже можно увидеть результат применения вышеназванного фильтра в виде тисненного (рельефного) варианта так излюбленного нами изображения со зданиями:

image

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

Примеры сегментации и наложения изображений

В данном разделе нашей статьи мы с вами будем работать над растровыми изображениями из файлов koshka.jpg и monastyr.jpg, взятыми из соответствующего архива и сохраненными в подкаталог original-pictures рабочего каталога нашего проекта.

Примечание: Исходные растровые изображения, используемые в практических примерах для данной статьи, рекомендуется скачать из Архива исходных растров, а затем распаковать в подкаталог original-pictures, находящейся в рабочем каталоге нашего проекта.

Вот как выглядят те изображения, которые нам понадобится использовать в данном разделе:

image

image

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

Обработка изображений с применением порогового значения

Итак, для выполнения поставленной выше задачи, поработаем сперва с файлом koshka.jpg, где нам сначала нужно будет удалить фон, используя методы сегментации изображения. С этой целью мы должны будем вначале произвести черновую сегментацию изображения кошки, используя методы пороговой обработки.

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

from PIL import Image
 
filename_koshka = "original-pictures/koshka.jpg"
with Image.open(filename_koshka) as img_koshka:
    img_koshka.load()
 
    img_koshka = img_koshka.crop((800, 0, 1650, 1281))
    img_koshka.show()

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

image

Как уже упоминалось раннее в этой статье, каждый пиксель цветного RGB изображения представлен в цифровом виде тремя числами, соответствующими значениям насыщенности для красного, зеленого и синего цвета этого пикселя. Пороговое значение же – это своеобразный эталон, ориентируясь на который насыщенность каждого пикселя в изображении преобразовывается либо в максимальное, либо в минимальное возможное значение в зависимости от того, какой была исходная насыщенность этих пикселей по отношение к вышеназванному эталону. Давайте посмотрим, как можно применять это пороговое значение на примере изображения со все той же кошкой в градациях серого:

    img_koshka_gray = img_koshka.convert("L")
    img_koshka_gray.show()
    porog = 100
    img_koshka_porog = img_koshka_gray.point(lambda x: 255 if x > porog else 0)
    img_koshka_porog.show()

В рамках данного кода пороговое значение равное 100 применяется к изображению кошки посредством использования метода .point(). Так, исходные значения каждого пикселя нашего изображения в градациях серого с помощью метода .point() сравниваются с вышеуказанным порогом и преобразовываются либо в 255 если они больше значения порога, либо в 0 если они меньше чем этот заданный порог.

Рисунок ниже показывает оригинал изображения в градациях серого и результат его обработки с по пороговому значению равному 100:

image

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

Пороговое значение, главным образом, используется для выделения тех объектов изображения, которые значительно отличаются от фона. Такого существенного отличия объекта от фона можно, в частности добиться за счет увеличения контрастности исходного изображения. В нашем примере можно попробовать повысить контрастность картинки, преобразуя ее не в градации серого, а выделяя из нее синее одноканальное изображение. Дело в том, что доминирующими цветами фона в нашем изображении являются коричневый и зеленый, которые имеют слабую синюю составляющую. Следовательно, синий канал исходного изображения даст более четкие и контрастные края фона.

Мы можем извлечь красный, зеленый и синий каналы из цветного изображения уже знакомым нам способом, упоминаемом раннее в этой статье:

    red, green, blue = img_koshka.split()
    red.show()
    green.show()
    blue.show()

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

image

Как видно из этих трех изображений, одноканальная картинка синего цвета имеет более высокий контраст между пикселями фона и пикселями, составляющими кошку. Давайте попробуем применить пороговое значение именно к одноканальной синей картинки, а также внести еще пару изменений для получения максимального эффекта черновой сегментации для нашего изображения:

    red, green, blue = img_koshka.split()
    porog = 57
    img_koshka_porog = blue.point(lambda x: 255 if x > porog else 0)
    img_koshka_porog = img_koshka_porog.convert("1")
    img_koshka_porog.show()

В этом примере мы подобрали для обработки нашей кошки более подходящее пороговое значение, равное 57. Кроме того, дополнительно в этом коде мы производим конвертацию нашего изображения с уже примененным пороговым значением в двоичный режим, используя (1) в качестве аргумента для метода .convert(). Этот двоичный или иначе – дуплексный режим обеспечивает вывод черно-белого изображения, где пиксели могут иметь значения лишь равные (0) или (1).

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

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

Результат итоговой картинки кошки с черновой сегментированной обработкой по пороговому значению приведен ниже:

image

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

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

Применение эффектов эрозии и дилатации для обработки изображений

Для более наглядного разъяснения сущности эффектов эрозии и дилатации, мы с вами в этом подразделе нашей статьи будем использовать изображение из файла tochka_i_otverstiye.jpg, находящегося в подкаталоге original-pictures рабочего каталога нашего проекта.

image

В левой части данного дуплексного (бинарного) изображения показана белая точка на черном фоне. Правая же часть этого изображения представляет собой инверсивный вариант ее левой части, где черное отверстие размещено на сплошном белом полотне.

Давайте теперь с вами разберемся, что же собой представляет эффект эрозии. Эрозия – это процесс удаления белых пикселей с границ бинарного изображения, который можно осуществить за счет фильтра ImageFilter.MinFilter(3), применяемого в качестве аргумента к методу .filter() из библиотеки Pillow. Этот фильтр работает с матрицей ядра размерностью 3х3 таким образом, что меняет значение центрального пикселя этой матрицы на то наиболее минимальное значение, которое по факту оказывается у одного из его соседних пикселей. Для дуплексного изображения такой алгоритм действий означает, что если любой из соседних пикселей центрального пикселя нашей матрицы будет иметь нулевое значение, то сам этот центральный пиксель должен будет также приравниваться к нулю.

Мы можем воочию увидеть эффект эрозии, несколько раз применив фильтр ImageFilter.MinFilter(3) к изображению tochka_i_otverstiye.jpg:

from PIL import Image, ImageFilter
 
filename = "original-pictures/tochka_i_otverstiye.jpg"
with Image.open(filename) as img:
    img.load()
 
    for _ in range(3):
        img = img.filter(ImageFilter.MinFilter(3))
 
    img.show()

Воспользовавшись в данном коде циклом for и применив за счет этого к нашему дуплексному изображению троекратный фильтр ImageFilter.MinFilter(3), мы получим следующий двоичный растр:

image

В результате применения эффекта эрозии, на этом изображении видно, что наша белая точка уменьшилась, а черное отверстие, в сущности, осталось прежним.

Противоположным эрозии является эффект дилатации, предполагающий добавление белых пикселей к границам бинарного изображения. Из модуля ImageFilter библиотеки Pillow выполнить процедуру дилатации можно посредством применения фильтра ImageFilter.MaxFilter(3), благодаря которому каждый пиксель изображения, при наличии у любого из его соседних пикселей белого цвета, также будет преобразован в белый цвет.

Давайте попробуем применить эффект дилатации к уже знакомому нам дуплексному изображению с белой точкой и черным отверстием:

filename = "original-pictures/tochka_i_otverstiye.jpg"
with Image.open(filename) as img:
    img.load()
 
    for _ in range(3):
        img = img.filter(ImageFilter.MaxFilter(3))
 
    img.show()

Нижеприведенное изображение наглядно показывает то, что дилатация приводит к противоположному эффекту, нежели эрозия:

image

Мы можем использовать эрозию и дилатацию совместно с тем, чтобы удалить мелкие черные или белые объекты из дуплексных изображений. Так, на примере нашего изображения с черным отверстием и белой точкой мы можем сначала в цикле применить десятикратный эффект эрозии и, тем, самым удалить белую точку, а затем, опять же в цикле использовать десятикратный эффект дилатации с тем, чтобы восстановить исходный размер черного отверстия:

with Image.open(filename) as img:
    img.load()
 
    for _ in range(10):
        img = img.filter(ImageFilter.MinFilter(3))
 
    img.show()
    for _ in range(10):
        img = img.filter(ImageFilter.MaxFilter(3))
 
    img.show()

После применения множественного эффекта эрозии в рамках вышеприведенного кода наше дуплексное изображение будет выглядеть следующим образом:

image

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

image

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

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

def eroziya(cycles, image):
    for _ in range(cycles):
        image = image.filter(ImageFilter.MinFilter(3))
    return image
 
def dilatatsiya(cycles, image):
    for _ in range(cycles):
        image = image.filter(ImageFilter.MaxFilter(3))
    return image

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

Сегментация изображения с помощью порогового значения

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

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

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

Итак, продолжим творить нашу магию и к уже полученному нами раннее черновому варианту изображения с сегментированной кошкой, сохраненному в переменной img_koshka_porog попробуем применить серию эффектов эрозии для удаления, содержащихся в фоне этого изображения белых вкраплений:

    shag_1 = eroziya(12, img_koshka_porog)
    shag_1.show()

В результате применения всего лишь двух строк вышеприведенного кода, все белые вкрапления из фона нашего предыдущего изображения с черновым вариантом сегментации кошки, будут удалены:

image

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

    shag_2 = dilatatsiya(58, shag_1)
    shag_2.show()

После применения пятидесяти восьми циклов дилатации все черные пятна на самой кошки заполняются белым, порождая таким образом следующее изображение:

image

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

    maska_koshki = eroziya(45, shag_2)
    maska_koshki.show()

Результатом последних двух строк кода явится маска, которая будет уже достаточно пригодной для окончательной сегментации изображения кошки:

image

Однако, для создания полноценной маски кошки, нам нужно еще преобразовать ее изображение из дуплексного (двоичного) в режим с градациями серого, а также избавиться от имеющихся у этой маски грубых (ступенчатых) углов посредством применения фильтра размытия:

    maska_koshki = maska_koshki.convert("L")
    maska_koshki = maska_koshki.filter(ImageFilter.BoxBlur(20))
    maska_koshki.show()

В итоге, после преобразования изображения в режим с градациями серого и, последующего применения к нему фильтра размытия ImageFilter.BoxBlur(), мы получаем следующую маску кошки:

image

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

    blank = img_koshka.point(lambda _: 0)
    segment_koshki = Image.composite(img_koshka, blank, maska_koshki)
    segment_koshki.show()

В рамках вышеприведенного кода для переменной blank сначала создается новое пустое изображение того же размера, что и img_koshka. При этом все значения пикселей в данном изображении с помощью метода .point() мы устанавливаем равными нулю, тем самым получая в переменной blank изображение с абсолютно черным фоном. Затем, воспользовавшись функцией composite() из библиотеки  Pillow  на основе PIL.Image  мы накладываем изображение img_koshka на пустое черное изображение blank через соответствующую маску maska_koshki, определяющую, какие части img_koshka фактически нужно наложить на blank. В итоге, получается следующее изображение:

image

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

Наложение изображений с использованием Image.paste()

В этом подразделе статьи мы наконец-то поместим изображение кошки с уже удаленным фоном в изображение монастырского дворика из файла monastyr.jpg, находящегося в подкаталоге original-pictures рабочего каталога нашего проекта. Все вышеописанное можно проделать с помощью нижеследующего кода:

    filename_monastyr = "original-pictures/monastyr.jpg"
    with Image.open(filename_monastyr) as img_monastyr:
        img_monastyr.load()
 
        img_monastyr.paste(
            img_koshka.resize((img_koshka.width // 5, img_koshka.height // 5)),
            (1300, 750),
            maska_koshki.resize((maska_koshki.width // 5, maska_koshki.height // 5)),)
       
        img_monastyr.show()

В рамках вышеприведенного кода для вставки картинки с кошкой в изображение монастырского дворика мы к данному изображению с двориком применяем метод .paste(), использующий в качестве параметров следующие три аргумента:

  • Аргумент 1 – является вставляемым изображением, которое в нашем случае представляет собой изображение с кошкой, сохраняемое в переменной img_koshka. Для обеспечения соразмерности кошки с двориком, мы нашу кошку для данного аргумента уменьшаем в пять раз, используя оператор целочисленного деления (//).
  • Аргумент 2 – представляет собой координаты левого верхнего угла того места на основном изображении, куда мы хотим вставить изображение из первого аргумента метода .paste(). В нашем случае, здесь в виде двухэлементного кортежа мы прописываем координаты на изображении монастырского дворика, где должен располагаться левый верхний угол накладываемого изображения с нашей кошкой.
  • Аргумент 3 – является растром с маской, позволяющей отображать на основном изображении лишь ту часть картинки из первого аргумента, которая вкладывается в белую область данной маски. В нашем случае, на изображение монастырского дворика мы налаживаем изображение лишь нашей пятикратно уменьшенной кошки без какого-либо фона.

Таким образом, в качестве третьего аргумента метода .paste() в вышеприведенном коде используется как раз тот результат нашей магии, который мы с вами творили на протяжении всего этого раздела, применяя к изображению с кошкой всевозможные вариации эффектов порогового значения, эрозии и дилатации. Но, все же, согласитесь, что приведенный в нижеследующим изображении результат нашей с вами с вами кропотливой работы нисколько не разочаровывает:

image

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

Создание водяного знака

На завершающем этапе работы с объединенным растром кошки и монастырского дворика нам с вами дополнительно еще нужно будет наложит на это изображение водяной знак с логотипом Онлайн школы по обучению профессиональному программированию PYLOT. С этой целью, файл с данным логотипом pylot-logo.png, предварительно входящий в состав Архива исходных растров мы, как обычно возьмем в подкаталоге original-pictures, расположенном в рабочем каталоге нашего проекта.

Итак, прежде, давайте загрузим вышеназванный логотип в память нашего компьютера:

logo = "original-pictures/pylot-logo.png"
img_logo = Image.open(logo)
img_logo.show()

В результате выполнения, приведенного выше кода на экране, должен отобразится полноразмерный логотип PYLOT в цвете:

image

Далее, этот логотип мы должны перевести в режим с градациями серого, а затем, через метод .point() применить к нему эффект порогового значения с тем, чтобы преобразовать логотип в черно-белое изображение. После этого, результирующее изображение мы должны еще уменьшить в два раза и преобразовать его в контурный рисунок:

img_logo = img_logo.convert("L")
porog = 50
img_logo = img_logo.point(lambda x: 255 if x > porog else 0)
img_logo = img_logo.resize((img_logo.width // 2, img_logo.height // 2))
img_logo = img_logo.filter(ImageFilter.CONTOUR)
img_logo.show()

В результате выполнения этого кода будет отображен контур логотипа PYLOT, который уже можно было бы использовать в качестве водяного знака для нашего объединенного изображения кошки в монастырском дворике:

image

Однако, перед использованием данного изображения в качестве водяного знака, нам необходимо дополнительно его инвертировать таким образом, чтобы цвет его фона стал бы черным, оставив белым только лишь его контур с соответствующим логотипом. Для выполнения такого инвертирования нам опять придется применить метод .point() в виде следующего кода:

img_logo = img_logo.point(lambda x: 0 if x == 255 else 255)
img_logo.show()

Благодаря выполнению этих двух программных строчек всем пикселям изображения с контуром нашего логотипа, имеющим значение 255 мы присваиваем значение 0, преобразовывая тем самым данные пиксели из белых в черные. Всем же остальным пикселям на этом изображении мы назначаем белый цвет. Таким образом, у нас получается инвертированное изображение с контуром логотипа PYLOT приведенное ниже:

image

На финальный шаге нам остается наложить уже подготовленный водяной знак с контуром логотипа PYLOT на наше объединенное изображение кошки, сидящей в монастырском дворике. С этой целью, как и в предыдущем разделе данной статьи, мы снова воспользуемся методом .paste():

img_monastyr.paste(img_logo, (480, 160), img_logo)
img_monastyr.show()

Здесь, как мы уже знаем, в первом аргументе метода .paste() указывается то изображение, которое мы хотим наложить, а в третьем аргументе – задается маска для данного накладываемого изображения. В нашем случае, как в качестве налаживаемого изображения, так и в качестве его маски используется одно и то же дуплексное (черно-белое) изображение с контуром нашего логотипа. Такое дублирование первого и третьего аргументов в методе .paste() допустимо лишь тогда, когда на холст (основное изображение) накладываются черно-белые растры, которыми, как правило, являются водяные знаки. Второй же аргумент вышеназванного метода, как нам уже должно быть известно из разъяснений в предыдущем подразделе статьи, указывает координаты точки, куда будет помещен верхний левый угол растра с контурами нашего логотипа.

Итак, теперь наконец-то мы получили наше итоговое изображение монастырского дворика с задумчиво сидящей в нем кошечкой и наложенным на всю эту идиллию водяным знаком в виде логотипа Онлайн школы по обучению профессиональному программированию PYLOT:

image

Как видно из вышеприведенного изображения, размещенный на нем водяной знак имеет прямоугольный контур, который получился в результате воздействия, используемого раннее, контурного фильтра ImageFilter.CONTOUR. Для удаления этого контура из нашего логотипа можно применить метод .crop(), который позволяет обрезать ненужные нам границы изображения по заданным координатам.

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

Манипулирование изображениями с помощью NumPy и Pillow

Как мы с вами уже убедились, библиотека Pillow имеет обширный арсенал встроенных функций и фильтров. Однако, зачастую бывают случаи, когда возможностей лишь только этой библиотеки крайне недостаточно и, нам в наших программах требуется реализовывать выполнение гораздо более сложных действий над изображениями.

Одним из вариантов решения данной проблемы может стать совместное использование с библиотекой Pillow такого идеально подходящего для взаимодействия с ней и весьма популярного ныне внешнего Python модуля, как NumPy, специализирующегося на работе с числовыми массивами.

Более подробно узнать о возможностях этого модуля NumPy можно, к примеру, из онлайн ресурса Изучение NumPy с визуальными примерами для начинающих.

Фишка применения NumPy во взаимодействии с Pillow состоит в том, что при конвертации изображения в массив для NumPy, у нас появляется возможность выполнять любые необходимые нам преобразования над каждым элементом данного массива, который в свою очередь, ассоциирован с каждым, имеющимся в изображении, пикселем. После же завершения всех нужных нам манипуляций, мы всегда с помощью библиотеки Pillow можем преобразовывать этот уже обработанный нами массив обратно в обычный Image-объект, содержащий результирующее изображение.

Но, прежде чем все это опробовать, нам предварительно в наш рабочий проект естественно нужно будет дополнительно установить последнюю версию модуля NumPy. С этой целью в командной оболочке нашего компьютера мы должны проделать следующие действия:

# Для Windows с использованием командной оболочки PowerShell
(venv) PS> python -m pip install numpy
 
# Для Linux подобных операционных систем с использованием командной оболочки Shell
(venv) $ python -m pip install numpy

Вот теперь, после установки NumPy, мы вовсе-оружие уже готовы к совершению дальнейшей магии над изображениями на основе взаимодействия данного модуля с библиотекой Pillow.

Вычитание изображений друг из друга с помощью NumPy

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

image

Выполнение, поставленной выше задачи, в сущности, не должно составлять для нас никакого труда. Однако, столь же несложно (если, не гораздо проще) можно выполнить данную задачу и путем написания специальной программки на Python, позволяющей вместо нас легко решит эту головоломку с нахождением различий в картинках. Давайте же попробуем сформировать такую программку, используя для нее в качестве сравниваемых два файла рисунков dom_sleva.jpg и dom_sprava.jpg, взятые все из того же, уже знакомого нам, подкаталога original-pictures, размещенного в рабочем каталоге нашего проекта.

В нижеприведенных нескольких строках кода, мы сначала загружаем два соответствующие изображения с помощью библиотеки Pillow, а затем преобразовываем их в массивы для NumPy:

import numpy as np
from PIL import Image
 
with Image.open("original-pictures/dom_sleva.jpg") as levo:
    levo.load()
 
with Image.open("original-pictures/dom_sprava.jpg") as pravo:
    pravo.load()
 
levo_array = np.asarray(levo)
pravo_array = np.asarray(pravo)
 
print(type(levo_array))
# <class 'numpy.ndarray'>
 
print(type(pravo_array))
# <class 'numpy.ndarray'>

В результате выполнения вышеприведенных строк кода наши рисунки стали массивами, являющимися объектами типа numpy.ndarray. Благодаря такому преобразованию, теперь мы можем применять к полученным массивам все те инструменты, которыми обладает наш внешний Python модуль NumPy.

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

raznitsa_array =  pravo_array - levo_array
print(type(raznitsa_array))
# <class 'numpy.ndarray'>

В рамках данного кода мы вычли друг из друга массивы одинакового размера, получив при этом новый массив с такой же размерностью, как и у двух исходных. Теперь мы уже смело можем преобразовывать результирующий массив обратно в изображение с помощью глобального метода Image.fromarray() из библиотеки Pillow:

raznitsa = Image.fromarray(raznitsa_array)
raznitsa.show()

Результатом вычитания массивов NumPy друг из друга и последующего преобразование в Image-объект библиотеки Pillow будет разностное изображение, которое приведено ниже:

image

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

Использование NumPy для создания изображений с нуля

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

import numpy as np
from PIL import Image
 
kvadrat = np.zeros((600, 600))
kvadrat[200:400, 200:400] = 255
 
kvadrat_img = Image.fromarray(kvadrat)
print(kvadrat_img)
# <PIL.Image.Image image mode=F size=600x600 at 0x1E76013BE20>
 
kvadrat_img.show()

В этом коде мы создаем массив kvadrat[] размером 600х600, все элементы которого за счет функции np.zeros() приравниваются к 0, что соответствует черному цвету пикселей в изображении. Затем, в центре этого массива kvadrat[] для всех его элементов, находящихся в строках от двухсотой по четырехсотую и в столбцах от двухсотого по четырехсотый, мы устанавливаем значение 255, что в соответствующем изображении будет аналогично пикселям белого цвета. Такая массовая инициализации группы элементов внутри массивов NumPy одним каким-то значением, обуславливается возможностью индексации этих массивов, как по строкам, так и по столбцам. В нашем случае, для созданного нами массива kvadrat[] к значению 255 приравниваются все элементы, указываемые  в его индексе отдельно для строк и, отдельно для столбцов. Причем, строки и столбцы, приведенные в индексе этого массива, также имеют у нас свои диапазоны, описанные в пределах от 200-го до 400-го элемента, как для строк, так и для столбцов данного массива.

После описанной выше обработки массива kvadrat[], мы в нашем коде с помощью уже знакомого нам глобального метода Image.fromarray() производим его преобразование в Image-объект с изображением, приведенным ниже:

image

Изначально для созданного нами и представленного выше изображения мы подразумевали вывод в градациях серого. Однако, глобальный метод Image.fromarray() автоматически назначил данному изображению цветной режим “F”, при котором каждый пиксель сформированного растра представляется 32-х битным значением с плавающей запятой. Для нас такой режим вывода изображения неприемлем хотя бы уже потому, что приводит к формированию растра слишком большого объема. Поэтому, нам следует преобразовать полученный нами рисунок в более простой и компактный режим с градациями серого, каждый пиксель которого представляется лишь 8-битным значением. Как мы уже знаем из предыдущих практических примеров данной статьи, этот режим обозначается буквой “L”:

kvadrat_img = kvadrat_img.convert("L")

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

import numpy as np
from PIL import Image
 
krasnoye = np.zeros((600, 600))
zelenoye = np.zeros((600, 600))
sineye = np.zeros((600, 600))
 
krasnoye[150:350, 150:350] = 255
zelenoye[200:400, 200:400] = 255
sineye[250:450, 250:450] = 255
 
krasnoye_img = Image.fromarray(krasnoye).convert("L")
zelenoye_img = Image.fromarray(zelenoye).convert("L")
sineye_img = Image.fromarray((sineye)).convert("L")

В ходе выполнения данного примера мы по каждому из трех numPy массивов создаем Image-объект, который затем при помощи метода convert() с аргументом “L” конвертируем в режим с оттенками серого. Получив таким образом три Image-объекты с одноканальными изображениями, мы теперь можем объединить эти изображения в один цветной растр, выводящейся в RGB режиме. С целью такого объединения нам нужно воспользоваться уже знакомым нам глобальным методом Image.merge()*:

kvadraty_img = Image.merge("RGB", (krasnoye_img, zelenoye_img, sineye_img))
 
print(kvadraty_img)
# <PIL.Image.Image image mode=RGB size=600x600 at 0x7FC7C817B9D0>
 
kvadraty_img.show()

Здесь, первый аргумент этого глобального метода Image.merge() представляет собой режим выводимого изображения. Вторым же аргументом данного метода является последовательность отдельных одноканальных изображений. В результате выполнения вышеприведенного кода формируется следующий рисунок:

image

Таким образом, мы объединили отдельные одноканальные квадраты разного цвета в одно цветное изображение, выводящиеся в RGB режиме.

В последнем, итоговом подразделе этого раздела нашей статьи мы с вами за счет взаимодействия внешнего модуля NumPy с библиотекой Pillow научимся в Python создавать GIF-анимацию и сохранять ее на диске в виде файла.

Создание и сохранение GIF-анимации

В предыдущем подразделе данной статьи мы уже создали цветное изображение, содержащее три пересекающихся квадрата разных цветов. Теперь же, на основе данного изображения давайте создадим с вами анимацию, показывающую, как три квадрата в этом изображении сливаются в один белый квадрат. С этой целью нам нужно будет создать несколько вариантов изображений с нашими тремя квадратами, но так, чтобы расположение каждого из квадратов в каждом из вариантов создаваемых изображений несколько отличалось бы друг от друга:

import numpy as np
from PIL import Image
 
kvadrat_animatsii = []
for sdvig in range(0, 100, 2):
    krasnoye = np.zeros((600, 600))
    zelenoye = np.zeros((600, 600))
    sineye = np.zeros((600, 600))
    krasnoye[101 + sdvig: 301 + sdvig, 101 + sdvig: 301 + sdvig] = 255
    zelenoye[200:400, 200:400] = 255
    sineye[299 - sdvig: 499 - sdvig, 299 - sdvig: 499 - sdvig] = 255
    krasnoye_img = Image.fromarray(krasnoye).convert("L")
    zelenoye_img = Image.fromarray(zelenoye).convert("L")
    sineye_img = Image.fromarray((sineye)).convert("L")
    kvadrat_animatsii.append(Image.merge("RGB", (krasnoye_img,
                                                 zelenoye_img, sineye_img)))
...

В первых строках приведенного выше кода мы создаем пустой список kvadrat_animatsii[], предназначаемый для сохранения в нем всех, созданных нами в последующем, вариантов изображений. Затем, в цикле for, по аналогии с практическим примером из предыдущего раздела, нами создаются NumPy массивы для красного, зеленого и синего каналов. При этом, массив со значениями для зеленых пикселей представляет собой квадрат в центре рисунка, который всегда будет оставаться неизменным. Массив же со значениями для красных пикселей, ассоциируемый с красным квадратом за счет меняющейся в цикле for переменной sdvig должен перемещаться из левого верхнего угла общего изображения к его центру. В сущности, этот красный квадрат с каждым новым циклом for (кадром) на заданный в этом цикле шаг будет двигаться наискосок все ближе и ближе к центру, а когда его достигнет – остановится. Синий же квадрат по аналогии с его красным собратом, также будет перемещаться к центру изображения, но только стартовать он будет не из левого верхнего, а из правого нижнего угла общего изображения.

Следует обратить внимание, что в вышеприведенном коде для цикла for устанавливается предел range(0, 100, 2), обеспечивающий изменение значения переменной sdvig от 0 до 100 с приращением на 2 для каждой новой итерации данного цикла. Это, в сущности, означает, что с каждым новым циклом (кадром) наши красный и синий квадраты будут изменять свое месторасположения на 2 пикселя по горизонтали и на 2 пикселя по вертикале.

В результате выполнения, приведенного выше кода, у нас формируется список с последовательностью из 50-ти изображений, которые нам нужно будет сохранить в один GIF файл, содержащий соответствующую анимацию. Ранее мы уже использовали метод .save(), позволяющий сохранять Image-объекты в файл. Этот же метод мы применим и сейчас для сохранения упомянутой выше последовательности изображений в файл с GIF-анимацией.

Однако, тут все же следует учитывать некоторую особенность сохранения анимации в GIF файл с помощью .save(), при которой данный метод должен всегда вызываться для первого элемента (изображения) в списке с последовательностью сохраняемых файлов. В нашем случае, мы вышеназванный метод должны вызывать для первого, полученного нами изображения в списке kvadrat_animatsii[]:

kvadrat_animatsii[0].save(
    "resulting-images/animatsiya.gif", save_all = True,
    append_images = kvadrat_animatsii[1:])

Как мы уже знаем, в первом аргументе метода .save() указывается путь и имя файла, который нам требуется сбросить на диск. При этом, расширение в имени данного файла указывает данному методу, в каком формате этот файл ему нужно сохранить. Но, еще одной особенностью сохранения GIF анимации в файл является то, что в вышеназванном методе .save() дополнительно также еще нужно указать в качестве аргументов два следующих ключевых слова:

  • save_all=True – обеспечивает сохранение всех имеющихся изображений в последовательности, начиная с первого.
  • append_images=square_animation[1:] – позволяет добавлять оставшиеся изображения из последовательности в файл GIF.

Приведенный выше код сохраняет всю последовательность из 50-ти изображений списка kvadrat_animatsii[] в GIF файл animatsiya.gif, размещаемый в подкаталоге resulting-images каталога с нашим рабочем проектом. Теперь, этот файл уже можно будет открыть с помощью любого приложения для просмотра или работы с имеющимися в нем изображениями. При необходимости зацикливание анимации в сохраняемом файле, в метод .save() нам следует добавить еще один аргумент в виде ключевого слова loop=0. Полученная в нашем сохраненном файле animatsiya.gif будет выглядеть следующим образом:

image

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

Выводы

В этой статье мы с вами довольно подробно рассмотрели все основные возможности использования библиотеки Pillow для работы с растровыми изображениями в аспекте разнообразных способов их обработки и модификации. Очень хотелось бы надеяться, что благодаря этой статье вы получили настоящий драйв от работы с графикой и, по-настоящему влюбились в мир обработки растровых изображений. Но, в любом случае, эта статья должна являться лишь началом ваших потенциальных познаний мира цифровой графики и, при желании работать в этой области, вам предстоит еще очень многое понять и узнать об этом безусловно чарующим цифровом мире растров. Отличным же стартом для этого может быть пособие Цифровая обработка изображений Гонсалеса и Вудса, которое является классическим учебником в области цифровой графики.

Pillow – конечно же, является далеко не единственной библиотекой, которую можно использовать в Python для обработки изображений. Но, в случае, когда наша с вами цель будет сводиться сугубо к базовой обработке изображений, то методы, которые нами здесь были изучены, скорее всего окажутся как раз тем, что нам наиболее всего и нужно. Если же мы захотим углубиться в более продвинутые методы обработки изображений, например, для создания приложений в сфере машинного обучения и компьютерного распознавания, то Pillow в этом случае может как нельзя кстати пригодится в качестве первой ступени для изучения более специализированных библиотек типа OpenCV или scikit-image.

Из этой статьи, в частности, мы с вами узнали, как:

  • считывать изображения с помощью Pillow
  • выполнять основные операции над изображениями
  • использовать Pillow для обработки и модификации растров
  • совместно использовать NumPy и Pillow для сравнения или создания изображений с нуля
  • создавать анимации с помощью Pillow

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

Искренне желаем вам всяческой удачи и максимальной отдачи от полученных в этой статье знаний!

Практический Python для начинающих
Практический Python для начинающих

Станьте junior Python программистом за 7 месяцев

 7 месяцев

Возможно будет интересно

🏆 Hello, world! Python
Новичок
🏆 Hello, world!

Мы вчера запустили новый www.pylot.me. Должны были в следующую среду, но запустили вчера.

2022-10-04
Как практиковаться в Python? Python
Новичок
Как практиковаться в Python?

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

2022-10-19
Условные конструкции и сопоставление структурных шаблонов Шпаргалки
Новичок
Условные конструкции и сопоставление структурных шаблонов

Шпаргалка по условным конструкциям и сопоставлению структурных шаблонов

2022-11-09