Python - wprowadzenie

12 - Grafika - `Pillow` i `Pycairo`

Grafika bitmapowa (pikselowa, rastrowa) z pillow

Pracę z grafiką bitmapową w Pythonie pokażę na przykładzie biblioteki pillow. Stronę domową, wraz dokumentacją można znaleźć pod adresem: https://pypi.org/project/Pillow/.

Zacznijmy od instalacji pillow:

W środowisku Anaconda wykonaj polecenie:

conda install pillow

Zaimportujmy odpowiednie moduły, które wykorzystamy.

import os
from PIL import Image, ImageEnhance, ImageDraw, ImageFont, ImageFilter

Do kolejnych ćwiczeń użyjemy poniższej fotografii (choć możesz użyć własnej). Pobierz ją i zapisz w miejscu, w którym uruchamiasz kod w podkatalogu zdjecia.

Lokomotywa

Napiszmy funkcję, która będzie wyświetlała plik a także drukowała niektóre informacje na temat pliku. Będziemy ją dalej wykorzystywać do podglądania efektów działań na zdjęciu.

def sprawdz_plik(sciezka, plik):
    # Otwarcie pliku
    foto = Image.open(os.path.join(sciezka, plik))
    # Wyświetlenie obrazu
    foto.show()
    # Wydruk danych o pliku
    print(f'Plik: {plik},\
    format: {foto.format},\
    rozmiar: {foto.size},\
    tryb: {foto.mode}')

Użyjmy funkcji sprawdz_plik() do wyświetlenia pliku i sprawdzenia jego parametrów:

# Ścieżka do katalogu ze zdjęciem
sciezka = 'zdjecia'
plik = 'lokomotywa.jpg'
# Wyświetlenie pliku i wydruk informacji
sprawdz_plik(sciezka, plik)
Plik: lokomotywa.jpg,    format: JPEG,    rozmiar: (700, 525),    tryb: RGB

Lokomotywa

Teraz wykonamy kolejno serię manipulacji na zdjęciu, aby pokazać niektóre (!) możliwości pillow.

Eksport z formatu JPG do PNG:

# Otwarcie pliku
foto = Image.open(os.path.join(sciezka, plik))
# Konwersja do formatu png
nazwa, przedluzenie = os.path.splitext(plik)
plik_png = nazwa + ".png"
foto.save(os.path.join(sciezka,plik_png))
sprawdz_plik(sciezka, plik_png)
Plik: lokomotywa.png,    format: PNG,    rozmiar: (700, 525),    tryb: RGB

Lokomotywa

Tworzenie kopii o mniejszych rozmiarach:

# Tworzenie kopii o zmniejszonych wymiarach 
# kopiujemy zdjęcie
foto_maly = foto.copy()
# zmiana rozmiaru kopii do maksymalnie 400x400 px
# proporcje są zachowane
foto_maly.thumbnail((400, 400))
plik_maly = nazwa+'_maly.jpg'
foto_maly.save(os.path.join(sciezka, plik_maly),'JPEG')
sprawdz_plik(sciezka, plik_maly)
Plik: lokomotywa_maly.jpg,    format: JPEG,    rozmiar: (400, 300),    tryb: RGB

Lokomotywa

Obrót zdjęcia o 90, 180 i 270 stopni z użyciem metody transpose()

# Obracanie obrazka o 90 stopni
foto_90 = foto.transpose(Image.ROTATE_90)
foto_90.save(os.path.join(sciezka, nazwa + '_90.jpg'),'JPEG')
sprawdz_plik(sciezka, nazwa + '_90.jpg')
Plik: lokomotywa_90.jpg,    format: JPEG,    rozmiar: (525, 700),    tryb: RGB

Lokomotywa

# Obracanie obrazka o 180 stopni
foto_180 = foto.transpose(Image.ROTATE_180)
foto_180.save(os.path.join(sciezka, nazwa + '_180.jpg'),'JPEG')
sprawdz_plik(sciezka, nazwa + '_180.jpg')
Plik: lokomotywa_180.jpg,    format: JPEG,    rozmiar: (700, 525),    tryb: RGB

Lokomotywa

# Obracanie obrazka o 270 stopni
foto_270 = foto.transpose(Image.ROTATE_270)
foto_270.save(os.path.join(sciezka, nazwa + '_270.jpg'),'JPEG')
sprawdz_plik(sciezka, nazwa + '_270.jpg')
Plik: lokomotywa_270.jpg,    format: JPEG,    rozmiar: (525, 700),    tryb: RGB

Lokomotywa

Obrót o dowolny kąt możemy osiągnąć używając metodę rotate()

# Obracanie obrazka o dowolny kąt 
# (w kierunku przeciwnym do ruchu wskazówek zegara!)
foto_30 = foto.rotate(30)
foto_30.save(os.path.join(sciezka, nazwa + '_30.jpg'),'JPEG')
sprawdz_plik(sciezka, nazwa + '_30.jpg')
Plik: lokomotywa_30.jpg,    format: JPEG,    rozmiar: (700, 525),    tryb: RGB

Lokomotywa

Łatwo możemy odwrócić zdjęcie w poziomie i w pionie:

# Odwracanie obrazka w poziomie
foto_LR = foto.transpose(Image.FLIP_LEFT_RIGHT)
foto_LR.save(os.path.join(sciezka, nazwa + '_LR.jpg'),'JPEG')
sprawdz_plik(sciezka, nazwa + '_LR.jpg')
Plik: lokomotywa_LR.jpg,    format: JPEG,    rozmiar: (700, 525),    tryb: RGB

Lokomotywa

# Odwracanie obrazka w w pionie
foto_TB = foto.transpose(Image.FLIP_TOP_BOTTOM)
foto_TB.save(os.path.join(sciezka, nazwa + '_TB.jpg'),'JPEG')
sprawdz_plik(sciezka, nazwa + '_TB.jpg')
Plik: lokomotywa_TB.jpg,    format: JPEG,    rozmiar: (700, 525),    tryb: RGB

Lokomotywa

Zmieńmy kontrast zdjęcia:

# Zmiana kontrastu - zwiększenie
foto_contr = ImageEnhance.Contrast(foto)
foto_contr.enhance(1.7).save(os.path.join(sciezka, nazwa + '_contrast_zwieksz.jpg'),'JPEG')
sprawdz_plik(sciezka, nazwa + '_contrast_zwieksz.jpg')
Plik: lokomotywa_contrast_zwieksz.jpg,    format: JPEG,    rozmiar: (700, 525),    tryb: RGB

Lokomotywa

# Zmiana kontrastu - zmniejszenie
foto_contr = ImageEnhance.Contrast(foto)
foto_contr.enhance(0.2).save(os.path.join(sciezka, nazwa + '_contrast_zmniejsz.jpg'),'JPEG')
sprawdz_plik(sciezka, nazwa + '_contrast_zmniejsz.jpg')
Plik: lokomotywa_contrast_zmniejsz.jpg,    format: JPEG,    rozmiar: (700, 525),    tryb: RGB

Lokomotywa

Możemy też pobrać wycinek zdjęcia:

# Wycinek zdjęcia
# Podajemy współrzędne prostokąta: lewy, górny, prawy, dolny
wycinek = (425, 250, 500, 350)
foto_wycinek = foto.crop(wycinek)
foto_wycinek.save(os.path.join(sciezka, nazwa + '_wycinek.jpg'),'JPEG')
sprawdz_plik(sciezka, nazwa + '_wycinek.jpg')
Plik: lokomotywa_wycinek.jpg,    format: JPEG,    rozmiar: (75, 100),    tryb: RGB

Lokomotywa

Przekonwertujmy teraz zdjęcie na odcienie szarości:

# Zmiana na fotografię w odcieniach szarości;
foto_szare = foto.convert('L')
foto_szare.save(os.path.join(sciezka, nazwa + '_szare.jpg'),'JPEG')
sprawdz_plik(sciezka, nazwa + '_szare.jpg')
Plik: lokomotywa_szare.jpg,    format: JPEG,    rozmiar: (700, 525),    tryb: L

Lokomotywa

Połączmy konwersję na odcienie szarości ze zwiększeniem kontrastu:

# Dodajemy kontrast
foto_szare_kontrast = ImageEnhance.Contrast(foto_szare)
foto_szare_kontrast.enhance(1.7).save(os.path.join(sciezka, nazwa + '_szare_contrast.jpg'),'JPEG')
sprawdz_plik(sciezka, nazwa + '_szare_contrast.jpg')
Plik: lokomotywa_szare_contrast.jpg,    format: JPEG,    rozmiar: (700, 525),    tryb: L

Lokomotywa

Teraz rozjaśnimy a potem przyciemnimy zdjęcie:

# Rozjaśnienie zdjęcia
foto_jasne = ImageEnhance.Brightness(foto)
foto_jasne.enhance(1.75).save(os.path.join(sciezka, nazwa + '_jasne.jpg'),'JPEG')
sprawdz_plik(sciezka, nazwa + '_jasne.jpg')
Plik: lokomotywa_jasne.jpg,    format: JPEG,    rozmiar: (700, 525),    tryb: RGB

Lokomotywa

# przyciemnienie zdjęcia
foto_ciemne = ImageEnhance.Brightness(foto)
foto_ciemne.enhance(0.50).save(os.path.join(sciezka, nazwa + '_ciemne.jpg'),'JPEG')
sprawdz_plik(sciezka, nazwa + '_ciemne.jpg')
Plik: lokomotywa_ciemne.jpg,    format: JPEG,    rozmiar: (700, 525),    tryb: RGB

Lokomotywa

Możemy też wyostrzyć i rozmyć obrazek:

# Wyostrzenie zdjęcia
foto_wyostrzone = ImageEnhance.Sharpness(foto)
foto_wyostrzone.enhance(5).save(os.path.join(sciezka, nazwa + '_wyostrzone.jpg'),'JPEG')
sprawdz_plik(sciezka, nazwa + '_wyostrzone.jpg')
Plik: lokomotywa_wyostrzone.jpg,    format: JPEG,    rozmiar: (700, 525),    tryb: RGB

Lokomotywa

# Rozmycie zdjęcia
foto_rozmyte = ImageEnhance.Sharpness(foto)
foto_rozmyte.enhance(0.01).save(os.path.join(sciezka, nazwa + '_rozmyte.jpg'),'JPEG')
sprawdz_plik(sciezka, nazwa + '_rozmyte.jpg')
Plik: lokomotywa_rozmyte.jpg,    format: JPEG,    rozmiar: (700, 525),    tryb: RGB

Lokomotywa

Efektu rozmywania nie widać zbyt dobrze na wygenerowanym zdjęciu, lepszy efekt uzyskamy stosując odpowiedni filtr (jest ich dostępnych oczywiście więcej):

foto_rozmyte_gaussian = foto.filter(ImageFilter.GaussianBlur(5))
foto_rozmyte_gaussian.save(os.path.join(sciezka, nazwa + '_rozmyte_gaussian.jpg'),'JPEG')
sprawdz_plik(sciezka, nazwa + '_rozmyte_gaussian.jpg')
Plik: lokomotywa_rozmyte_gaussian.jpg,    format: JPEG,    rozmiar: (700, 525),    tryb: RGB

Lokomotywa

Dodajmy do zdjęcia napis:

foto_kopia = foto.copy()
foto_napis = ImageDraw.Draw(foto_kopia)
# Font
font = ImageFont.truetype('arial.ttf', 40)
# fill oznacza kolor, można też określać go np. podając wartości RGB
foto_napis.text((10, 470),"Lokomotywa", font=font, fill='wheat')
foto_kopia.save(os.path.join(sciezka, nazwa + '_napis.jpg'),'JPEG')
sprawdz_plik(sciezka, nazwa + '_napis.jpg')
Plik: lokomotywa_napis.jpg,    format: JPEG,    rozmiar: (700, 525),    tryb: RGB

Lokomotywa

Narysujmy kilka figur:

foto_kopia = foto.copy()
foto_rysuj = ImageDraw.Draw(foto_kopia)
# Rysujemy linię
foto_rysuj.line((10, 10, 100, 200), fill='yellow', width=4)

# Rysujemy prostokąt
foto_rysuj.rectangle((100, 100, 200, 200),
                     # Kolor wypełnienia 
                     # Tym razem zastosujemy wartości RGB
                     fill=(255, 0, 0),
                     # kolor obrysu
                     outline=(0, 200, 0),
                     # grubość obrysu
                     width = 4)

# Rysujemy elipsę, która jest wypełnia prostokąt o podanych rozmiarach
foto_rysuj.ellipse((300, 300, 450, 400),
                     # Kolor wypełnienia 
                     fill=(200, 0, 100),
                     # kolor obrysu
                     outline=(100, 100, 100),
                     # grubość obrysu
                     width = 10)

foto_kopia.save(os.path.join(sciezka, nazwa + '_figury.jpg'),'JPEG')
sprawdz_plik(sciezka, nazwa + '_figury.jpg')
Plik: lokomotywa_figury.jpg,    format: JPEG,    rozmiar: (700, 525),    tryb: RGB

Lokomotywa

Zauważ, że układ współrzędnych używany powyżej (i w wielu innych innych przypadkach grafiki komputerowej) różni się od tego, którego używaliśmy w szkole. Lewy, górny róg obrazu ma współrzędne (0, 0) prawy dolny róg ma współrzędne wynikające z rozmiarów obrazu: (szerokosc, wysokosc).

Wspołrzędne

Używając pętli możemy oczywiście zautomatyzować modyfikację wielu zdjęć. Przykładowo, poniższy kod zmniejszy wielkość wszystkich zdjęć w katalogu (powinny się tam znajdować wyłącznie zdjęcia) do maksymalnego rozmiaru 100x100 pikseli i zapisuje je w formacie png.

# Katalog ze zdjęciami
katalog_glowny = 'fotografie'
# Katalog, w kórym znajdą się miniaturki
katalog_wyjsciowy = os.path.join(katalog_glowny, 'miniaturki')
# Lista plików/katalogów w katalogu ze zdjęciami
obrazki = os.listdir(katalog_glowny)
print(f'Plik/katalogi: {obrazki}')
# Przedłużenia plików ze zdjęciami (można dopisać więcej)
przedluzenia = ['.jpg', '.jpeg', '.png']

# Utworzenie pliku na miniaturki, jeśli nie istnieje
if os.path.exists(katalog_wyjsciowy):
    print(f'katalog:{katalog_wyjsciowy} istnieje')
else:
    print(f'Tworzę {katalog_wyjsciowy}')
    os.makedirs(katalog_wyjsciowy)

#Przetwarzanie każdego zdjęcia    
for plik in obrazki:
    nazwa, przedluzenie = os.path.splitext(plik)
    if (przedluzenie in przedluzenia):
        foto = Image.open(os.path.join(katalog_glowny, plik))
        nazwa, przedluzenie = os.path.splitext(plik)
        plik_maly = nazwa + "-miniatura.png"
        foto.thumbnail((100, 100))
        foto.save(os.path.join(katalog_wyjsciowy, plik_maly),'PNG')
Plik/katalogi: ['foto-5.jpg', 'foto-4.jpg', 'foto-1.jpg', 'foto-3.jpg', 'miniaturki', 'foto-2.jpg']
katalog:fotografie/miniaturki istnieje

Grafika wektorowa z użyciem modułu Pycairo

Do pracy z grafiką wektorową użyjemy modułu Pycairo, którego dokumentację możemy znaleźć pod adresem: https://pycairo.readthedocs.io/en/latest/.

Pycairo zainstalujemy poleceniem:

conda install pycairo

Narysujmy prosty rysunek i zapiszmy go w formacie PDF. Narysujemy prostokąt korzystając z metody rectangle(), którego wywołanie wygląda tak:

rectangle(x, y, szerokosc, wysokosc)

Gdzie:

  • x i y to współrzędne lewego, górnego rogu.
  • szerokosc i wysokosc określają rozmiar prostokąta.

Także w tym przypadku, lewy górny róg rysunku ma współrzędne (0, 0).

# Import modułu
import cairo
plik = 'rysunek_1.pdf'
szerokosc = 600
wysokosc = 400

# Tworzenie "surface" - obiektu w którym umieścimy rysunek
# przy tworzeniu określamy format, tu PDF
powierzchnia = cairo.PDFSurface(plik, szerokosc, wysokosc)

# Tworzenie "context" - na nim będziemy rysować
kontekst = cairo.Context(powierzchnia)

# Ustawiamy kolor (tła)
# Kolejne liczby określają wartości dla kanałów:
# R(ed), G(reen), B(lue)  w zakresie 0-1
kontekst.set_source_rgb(0.8, 0.8, 0.9)

# Wypełniamy obszar rysunku wybranym kolorem
kontekst.paint()

# Rysujemy prostokąt
kontekst.rectangle(150, 100, 320, 240)

# Określamy kolor
kontekst.set_source_rgb(0, 0, 0.5)

# Wypełniamy figurę
kontekst.fill()

# Zapis pliku
powierzchnia.finish()

Rysunek

W powyższym przykładzie utworzyliśmy powierzchnię (surface) w formacie PDF. W ten sposób określamy format wygenerowanego pliku. Dostępnych jest kilka rodzajów powierzchni służących generowaniu plików w określonym formacie:

  • ImageSurface- plik bitmapowy, w formacie PNG
  • PDFSurface - plik wektorowy w formacie PDF
  • PSSsurface - plik wektorowy w formacie PostScript (PS) lub Encapsulated PostScipt (EPS)
  • SVGSurface - plik wektorowy w formacie SVG

Są jeszcze inne dostępne powierzchnie, ale nie będziemy ich tu omawiać.

Wybór formatu generowanego pliku zależy oczywiście przede wszystkim od jego przeznaczenia. Jeśli zamierzamy później ręcznie modyfikować wygenerowany obraz, warto wybrać któryś z formatów wektorowych. Zwłaszcza formaty PDF czy SVG pozwalają na łatwą dalszą edycję w takich programach jak Inkscape.

Utwórzmy zatem podobny rysunek, tyle że w formacie SVG.

plik = 'rysunek_1.svg'
szerokosc = 600
wysokosc = 400
# Tworzenie "surface" - obiektu w którym umieścimy rysunek
# przy tworzeniu określamy format, tu SVG
powierzchnia = cairo.SVGSurface(plik, szerokosc, wysokosc)
# Tworzenie "context" - na nim będziemy rysować
kontekst = cairo.Context(powierzchnia)
# Ustawiamy kolor (tła)
kontekst.set_source_rgb(0.8, 0.8, 0.9)
# Wypełniamy obszar rysunku wybranym kolorem
kontekst.paint()
# Rysujemy prostokąt
kontekst.rectangle(150, 100, 320, 240)
# Określamy kolor
kontekst.set_source_rgb(0, 0, 0.5)
# Wypełniamy figurę
kontekst.fill()
# Zapis pliku
powierzchnia.finish()

Nieco inaczej będzie to wyglądało w przypadku pliku w formacie PNG. Zwróć uwagę na nieco inny sposób tworzenia powierzchni i zapisu pliku.

plik = 'rysunek_1.png'
szerokosc = 600
wysokosc = 400
powierzchnia = cairo.ImageSurface(cairo.FORMAT_RGB24, szerokosc, wysokosc)
kontekst = cairo.Context(powierzchnia)
# Ustawiamy kolor (tła)
kontekst.set_source_rgb(0.8, 0.8, 0.9)
# Wypełniamy obszar rysunku wybranym kolorem
kontekst.paint()
# Rysujemy prostokąt
kontekst.rectangle(150, 100, 320, 240)
# Określamy kolor
kontekst.set_source_rgb(0, 0, 0.5)
# Wypełniamy figurę
kontekst.fill()
# Zapis pliku
powierzchnia.write_to_png(plik)

Rysunek

Dalej będziemy generowali pliki w formacie PNG aby łatwiej podejrzeć wynik.

Linie proste, łamane i wielokąty

Zacznijmy od linii prostych, łamanych i wielokątów. Aby je narysować wykorzystamy metody move_to() oraz line_to():

plik = 'rysunek_2.png'
szerokosc = 400
wysokosc = 300
powierzchnia = cairo.ImageSurface(cairo.FORMAT_RGB24, szerokosc, wysokosc)
kontekst = cairo.Context(powierzchnia)
# Ustawiamy kolor (tła) na jasnoszary
kontekst.set_source_rgb(0.95, 0.95, 0.95)
# Wypełniamy obszar rysunku wybranym kolorem
kontekst.paint()
# Zmieniamy punkt początkowy na (10, 20)
kontekst.move_to(10, 20)
# Tworzymy linię do punktu (200, 200)
kontekst.line_to(200, 200)
# Ustawiamy kolor na czarny
kontekst.set_source_rgb(0, 0, 0)
# Szerokość linii
kontekst.set_line_width(10)
# Teraz rysujemy linię
kontekst.stroke()
# Zapis pliku
powierzchnia.write_to_png(plik)

Rysunek

Po wykonaniu metody line_to() zmienia się także punkt początkowy, od którego możemy tworzyć np. następną linię. Umożliwia to rysowanie krzywych:

plik = 'rysunek_3.png'
szerokosc = 400
wysokosc = 300
powierzchnia = cairo.ImageSurface(cairo.FORMAT_RGB24, szerokosc, wysokosc)
kontekst = cairo.Context(powierzchnia)
# Ustawiamy kolor (tła) na jasnoszary
kontekst.set_source_rgb(0.95, 0.95, 0.95)
# Wypełniamy obszar rysunku wybranym kolorem
kontekst.paint()
# Zmieniamy punkt początkowy na (100, 200)
kontekst.move_to(10, 20)
# Tworzymy linię do punktu (150, 300)
kontekst.line_to(200, 200)
# Kolejne linie:
kontekst.line_to(100, 200)
kontekst.line_to(300, 20)
kontekst.line_to(300, 290)
kontekst.line_to(390, 290)
# Ustawiamy kolor na czarny
kontekst.set_source_rgb(0, 0, 0)
# Szerokość linii
kontekst.set_line_width(10)
# Teraz rysujemy linię
kontekst.stroke()
# Zapis pliku
powierzchnia.write_to_png(plik)

Rysunek

Można też utworzyć zamknięte wielokąty a także je wypełnić:

plik = 'rysunek_4.png'
szerokosc = 400
wysokosc = 300
powierzchnia = cairo.ImageSurface(cairo.FORMAT_RGB24, szerokosc, wysokosc)
kontekst = cairo.Context(powierzchnia)
# Ustawiamy kolor (tła) na jasnoszary
kontekst.set_source_rgb(0.95, 0.95, 0.95)
# Wypełniamy obszar rysunku wybranym kolorem
kontekst.paint()
# Zmieniamy punkt początkowy na (100, 200)
kontekst.move_to(100, 100)
# Tworzymy linię do punktu (150, 300)
kontekst.line_to(200, 250)
kontekst.line_to(380, 150)
kontekst.line_to(250, 80)
kontekst.line_to(250, 10)
kontekst.line_to(200, 10)
# Zamykamy figurę
kontekst.close_path()
# Kolor wypełnienia
kontekst.set_source_rgb(0.2, 0, 0.8)
# Wypełniamy figurę
kontekst.fill()
# Ustawiamy kolor na czarny
kontekst.set_source_rgb(0, 0, 0)
# Szerokość linii
kontekst.set_line_width(10)
# Teraz rysujemy linię
kontekst.stroke()

# Zapis pliku
powierzchnia.write_to_png(plik)

Rysunek

Łuki i okręgi

Łuki i okręgi możemy rysować przy pomocy metody arc().

import math
plik = 'rysunek_5.png'
szerokosc = 400
wysokosc = 300
powierzchnia = cairo.ImageSurface(cairo.FORMAT_RGB24, szerokosc, wysokosc)
kontekst = cairo.Context(powierzchnia)
# Ustawiamy kolor (tła) na jasnoszary
kontekst.set_source_rgb(0.95, 0.95, 0.95)
# Wypełniamy obszar rysunku wybranym kolorem
kontekst.paint()
# Pierwszy łuk (okrąg)
kontekst.arc(200, 150, 120, 0, math.pi*2)
kontekst.set_source_rgb(0.5, 0.5, 1)
kontekst.set_line_width(10)
kontekst.stroke()

# Drugi łuk
kontekst.arc(200, 150, 80, math.pi/2, math.pi)
kontekst.set_source_rgb(1, 0.7, 0)
kontekst.set_line_width(10)
kontekst.stroke()

# Trzeci łuk
kontekst.arc(200, 150, 50, 0, math.pi)
kontekst.set_source_rgb(0.5, 0.9, 0.5)
kontekst.set_line_width(10)
kontekst.stroke()


# Zapis pliku
powierzchnia.write_to_png(plik)

Rysunek

Rysowanie łuków i okręgów wymaga pewnego wyjaśnienia.

Przyjrzyj się poniższemu schematowi:

Łuk

Wywołanie metody wygląda następująco:

arc(x, y, promień, kąt1, kąt2)

Współrzędne x i y wyznaczają punkt, który jest środkiem okręgu, o promieniu promień, z którego ,,wycinany'' jest łuk. Wyobraźmy sobie linię, która przebiega poziomo na wysokości wyznaczonej przez y (linia0). Zgodnie z ruchem zegara wyznaczany jest kąt1, który określa początek rysowania łuku, który sięga miejsca gdzie kończy się łuk2, który także bierze początek na linii0. Co ważne, kąty podajemy nie w stopniach, ale w radianach. 180° odpowiada wartości π (3,14…), zatem np. 90° będzie odpowiadało π/2, a dla pełnego okręgu (360°) będzie to 2*π. Radiany na stopnie możemy łatwo przeliczać wykorzystując moduł math:

# Printing radians equivalents.
print (f'180 stopni to       {math.radians(180)} radianów')
print (f'90 stopni to        {math.radians(90)} radianów')
print (f'30 stopni to        {math.radians(30)} radianów')
print (f'Pi radianów to      {math.degrees(math.pi)} stopni')
print (f'2 Pi radianów to    {math.degrees(2*math.pi)} stopni')
180 stopni to       3.141592653589793 radianów
90 stopni to        1.5707963267948966 radianów
30 stopni to        0.5235987755982988 radianów
Pi radianów to      180.0 stopni
2 Pi radianów to    360.0 stopni

Można też użyć innego sposobu:

stopnie = 180
print (f'{stopnie}*math.pi/180 to:  {stopnie*math.pi/180} radianów ({stopnie} stopni)')
stopnie = 90
print (f'{stopnie}*math.pi/180 to:  {stopnie*math.pi/180} radianów ({stopnie} stopni)')
stopnie = 30
print (f'{stopnie}*math.pi/180 to:  {stopnie*math.pi/180} radianów ({stopnie} stopni)')
180*math.pi/180 to:  3.141592653589793 radianów (180 stopni)
90*math.pi/180 to:  1.5707963267948966 radianów (90 stopni)
30*math.pi/180 to:  0.5235987755982988 radianów (30 stopni)

Spróbujmy teraz zastosować wypełnienie dla uzyskanych łuków (i okręgu).

import math
plik = 'rysunek_6.png'
szerokosc = 400
wysokosc = 300
powierzchnia = cairo.ImageSurface(cairo.FORMAT_RGB24, szerokosc, wysokosc)
kontekst = cairo.Context(powierzchnia)
# Ustawiamy kolor (tła) na jasnoszary
kontekst.set_source_rgb(0.95, 0.95, 0.95)
# Wypełniamy obszar rysunku wybranym kolorem
kontekst.paint()
# Pierwszy łuk (okrąg)
kontekst.arc(200, 150, 120, 0, math.pi*2)
kontekst.set_source_rgb(0.5, 0.5, 1)
kontekst.set_line_width(10)
kontekst.fill()
kontekst.stroke()

# Drugi łuk
kontekst.arc(200, 150, 80, math.pi/2, math.pi)
kontekst.set_source_rgb(1, 0.7, 0)
kontekst.set_line_width(10)
kontekst.fill()
kontekst.stroke()

# Trzeci łuk
kontekst.arc(200, 150, 50, 0, math.pi)
kontekst.set_source_rgb(0.5, 0.9, 0.5)
kontekst.set_line_width(10)
kontekst.fill()
kontekst.stroke()

# Zapis pliku
powierzchnia.write_to_png(plik)

Rysunek

Tekst

Na tworzonym rysunku, można też umieścić tekst. Poniższy kod pokazuje kilka przykładów pozwalających zrozumieć podstawowe zasady dotyczące umieszczenia i modyfikowania wyglądu tekstu. Zwróć zwłaszcza uwagę na umieszczenie tekstu względem zielonej linii. Jej początek, a także miejsce wstawienia pierwszego tekstu, to ten sam punkt.

plik = 'rysunek_7.png'
szerokosc = 600
wysokosc = 300

powierzchnia = cairo.ImageSurface(cairo.FORMAT_RGB24, szerokosc, wysokosc)
kontekst = cairo.Context(powierzchnia)
# Ustawiamy kolor (tła) na jasnoszary
kontekst.set_source_rgb(0.95, 0.95, 0.95)
# Wypełniamy obszar rysunku wybranym kolorem
kontekst.paint()

# Współrzędne punktów
x1, y1 = 50, 50
x2, y2 = 550, 50

# Linia zielona ("referencyjna")
kontekst.move_to(x1, y1)
kontekst.line_to(x2, y2)
kontekst.set_source_rgb(0, 1, 0)
kontekst.set_line_width(2)
kontekst.stroke()

# Definiujemy font i jego parametry
kontekst.select_font_face("Serif")
# Miejsce umieszczenie tekstu
kontekst.move_to(x1, y1)
# Kolor tekstu
kontekst.set_source_rgb(0.7, 0, 0)
# Rozmiar fontu 
kontekst.set_font_size(30)
# Umieszczenie tekstu 
kontekst.show_text("Wprowadzony tekst")
# Różne style fontu
kontekst.move_to(x1, y1 + 50)
kontekst.select_font_face('Serif', cairo.FontSlant.ITALIC)
kontekst.show_text("Wprowadzony tekst, italik")
kontekst.move_to(x1, y1 + 100)
kontekst.select_font_face('Serif', cairo.FontWeight.BOLD)
kontekst.show_text("Wprowadzony tekst, bold")
kontekst.move_to(x1, y1 + 150)
kontekst.select_font_face('Serif', cairo.FontSlant.ITALIC, cairo.FontWeight.BOLD)
kontekst.show_text("Wprowadzony tekst, italik, bold")
# Inny krój fontu
kontekst.move_to(x1, y1 + 200)
kontekst.select_font_face('Arial')
kontekst.show_text("Wprowadzony tekst, Arial")

powierzchnia.write_to_png(plik)

Rysunek

Krzywe Béziera

Krzywa Béziera pozwalają na tworzenie krzywych, nawet o dość złożonych kształtach. Nie będę tu głębiej omawiał terorii, moża o niej przeczytać np. na stronie Wikipedii https://pl.wikipedia.org/wiki/Krzywa_B%C3%A9ziera. W praktyce, aby ,,wyczuć'' ten sposób rysowania krzywych, warto poćwiczyć w programach przeznaczonych do tworzenia grafiki wektorowej, takich jak darmowy Inkscape.

Poniżej znajduje się kod, który generuje obrazek, pozwalający nieco wyjaśnić jak powstają takie krzywe. Wyobraźmy sobie, że mamy prostą (zielona) przebiegającą od punktu A i B. Następnie definiujemy punkty P1 i P2, które kontrolują kształt powstającej krzywej, co możemy sobie wyobrazić jako ,,przyciąganie'' krzywej.

Poniższy przykład tworzy dość prostą krzywą, możliwe jest tworzenie dużo bardziej skomplikowanych kształtów, czym jednak nie będziemy się tu zajmować.

plik = 'rysunek_8.png'
szerokosc = 450
wysokosc = 300
powierzchnia = cairo.ImageSurface(cairo.FORMAT_RGB24, szerokosc, wysokosc)
kontekst = cairo.Context(powierzchnia)
# Ustawiamy kolor (tła) na jasnoszary
kontekst.set_source_rgb(0.95, 0.95, 0.95)
# Wypełniamy obszar rysunku wybranym kolorem
kontekst.paint()

# Współrzędne punktów
x1, y1 = 50, 150
x2, y2 = 350, 150
px1, py1 = 100, 250
px2, py2 = 290, 50

# Linia zielona ("referencyjna")
kontekst.move_to(x1, y1)
kontekst.line_to(x2, y2)
kontekst.set_source_rgb(0, 1, 0)
kontekst.set_line_width(7)
kontekst.stroke()

# Krzywe Béziera (pomarańczowa)
kontekst.move_to(x1, y1)
kontekst.curve_to(px1, py1, px2, py2, x2, y2)
kontekst.set_source_rgb(1, 0.7, 0)
kontekst.set_line_width(10)
kontekst.stroke()

# Linie pomiędzy punktami A-P1, oraz B-P2
kontekst.move_to(x1, y1)
kontekst.line_to(px1, py1)
kontekst.move_to(x2, y2)
kontekst.line_to(px2, py2)
kontekst.set_source_rgb(0.7, 0.7, 1)
kontekst.set_line_width(2)
kontekst.stroke()

# Opisy 
kontekst.select_font_face("Arial")
kontekst.set_font_size(16)
kontekst.set_source_rgb(0, 0, 0)
kontekst.move_to(x1, y1)
kontekst.show_text("A (x1, y1)")
kontekst.move_to(x2, y2)
kontekst.show_text("B (x2, y2)")
kontekst.move_to(px1, py1)
kontekst.show_text("P1 (px1, py1)")
kontekst.move_to(px2, py2)
kontekst.show_text("P2 (px2, py2)")

# Zapis pliku
powierzchnia.write_to_png(plik)

Rysunek

Obrys i wypełnienie figur

Zajmijmy się teraz modyfikacjami stylów linii, obrysów a także wypełnień rysowanych kształtów.

plik = 'rysunek_9.png'
szerokosc = 350
wysokosc = 350
powierzchnia = cairo.ImageSurface(cairo.FORMAT_RGB24, szerokosc, wysokosc)
kontekst = cairo.Context(powierzchnia)
kontekst.set_source_rgb(1, 1, 1)
kontekst.paint()
x, y = 120, 120
r = 100

# Pierwsze koło - czerwony, nieprzeźroczysty
kontekst.arc(x, y, r, 0, math.pi*2)
# Szerokość obrysu
kontekst.set_line_width(10)
# Kolor wypełnienia: czerwony, nieprzeźroczysty
kontekst.set_source_rgb(1, 0, 0)
# Wypełnienie, ale wciąż pracujemy nad tą samą figurą
kontekst.fill_preserve()
# Kolor obrysu, nieprzeźroczysty
kontekst.set_source_rgb(0, 0, 0)
kontekst.stroke()

# Drugie koło - zielone, półprzeźroczyste
kontekst.arc(x + r, y, r, 0, math.pi*2)
kontekst.set_line_width(10)
# Kolor wypełnienia: zielony, półprzeźroczysty
kontekst.set_source_rgba(0, 1, 0, 0.5)
kontekst.fill_preserve()
# Kolor obrysu, półprzeźroczysty
kontekst.set_source_rgba(0, 0, 0, 0.5)
kontekst.stroke()

# Trzecie koło - gradient, częściowo przeźroczysty, bez obrysu
kontekst.arc(x + r/2, y + r, r, 0, math.pi*2)
pattern = cairo.LinearGradient(x + r/2, y, x + r/2, y + 2*r)
# Pierwszy kolor
pattern.add_color_stop_rgb(0, 1, 1, 0)
# Drugi kolor - półprzeźroczysty
pattern.add_color_stop_rgba(0.5, 1, 0, 0, 0.5)
# Trzeci kolor 
pattern.add_color_stop_rgb(1, 0, 0, 1)
kontekst.set_source(pattern)
kontekst.fill()

# Linia gradientu: biała - pokazuje przebieg gradientu
kontekst.move_to(x + r/2, y)
kontekst.line_to(x + r/2, y + 2*r)
kontekst.set_source_rgb(1, 1, 1)
kontekst.set_line_width(3)
kontekst.stroke()

powierzchnia.write_to_png(plik)

Rysunek

Większość kodu, zwłaszcza uwzględniając komentarze, powinna być zrozumiała. Zwrócę uwagę na kilka aspektów: Zauważ, że dla drugiego koła wykorzystaliśmy częściowo przeźroczyste wypełnienie i obrys:

kontekst.set_source_rgba(R, G, B, A)

Jak widać, poza standardowymi kanałami R(ed), G(reen), B(lue), doszedł czwarty (A)lpha pozwalający na ustawienie stopnia krycia, zatem możemy w ten sposób sprawić, że rysowane przez nas kształty są w różnym stopniu przeźroczyste.

Gradient liniowy (są też inne) określamy definiując przebieg linii gradientu (zaznaczony białą linią) wzdłuż, której zmieniają się kolory. Możemy ustawić kilka kolorów w określonych miejscach, pomiędzy którymi kolor przechodzi płynnie z jednego w drugi. Także one mogą być w różnym stopniu przeźroczyste.

Same linie, a także obrysy kształtów mogą mieć różne style a także rodzaje zakończeń. Zacznijmy od zakończeń. Wygeneruj rysunek za pomocą poniższego kodu i przeanalizuj wynik. Dwie pionowe, pomarańczowe linie wskazują na początek i koniec “właściwych linii'' (czarnych), których położenie jest wyznaczone początkowymi i końcowymi punktami.

plik = 'rysunek_10.png'
szerokosc = 300
wysokosc = 250
powierzchnia = cairo.ImageSurface(cairo.FORMAT_RGB24, szerokosc, wysokosc)
kontekst = cairo.Context(powierzchnia)
kontekst.set_source_rgb(0.9, 0.9, 0.9)
kontekst.paint()
x1, y1 = 50, 20
x2, y2 = 250, 20
# Odstęp między liniami
odstep = 50

# Linie referencyjne (pomarańczowe) - pokazują, 
# gdzie się zaczynają i kończą linie "właściwe"
grubosc = 4
kontekst.set_line_width(grubosc)
kontekst.set_source_rgb(1, 0.7, 0)
kontekst.move_to(x1, y1 - 10)
kontekst.line_to(x1, y2 + odstep * 4 + 10)
kontekst.stroke()
kontekst.move_to(x2, y1 - 10)
kontekst.line_to(x2, y2 + odstep * 4 + 10)
kontekst.stroke()


# "Właściwe"  linie
grubosc = 20

kontekst.set_line_width(grubosc)
kontekst.set_source_rgb(0, 0, 0)

# Linia
kontekst.move_to(x1, y1)
kontekst.line_to(x2, y2)
kontekst.stroke()

# Linia z zakończeniem zaokrąglonym
kontekst.set_line_cap(cairo.LINE_CAP_ROUND)
kontekst.move_to(x1, y1 + odstep)
kontekst.line_to(x2, y2+ odstep)
kontekst.stroke()

# Linia z zakończeniem kwadratowym
kontekst.set_line_cap(cairo.LINE_CAP_SQUARE)
kontekst.move_to(x1, y1 + odstep * 2)
kontekst.line_to(x2, y2 + odstep * 2)
kontekst.stroke()

# Brak dodatkowego zakończenia (domyślnie)
kontekst.set_line_cap(cairo.LINE_CAP_BUTT)
kontekst.move_to(x1, y1 + odstep * 3)
kontekst.line_to(x2, y2 + odstep * 3)
kontekst.stroke()

# Zaokrąglone zakończenie, dopasowane długością
# do linii bez zakończenia
kontekst.set_line_cap(cairo.LINE_CAP_ROUND)
kontekst.move_to(x1 + grubosc/2, y1 + odstep * 4)
kontekst.line_to(x2 - grubosc/2, y2 + odstep * 4)
kontekst.stroke()

powierzchnia.write_to_png(plik)

Rysunek

Zauważ, że po dodaniu zakończeń, linia wraz zakończeniem, staje się dłuższa. Jeśli chcemy utrzymać długość taką, jak przed dodaniem zakończeń, należy odpowiednio skrócić linie, co widać w piątej linii.

Teraz przyjrzyjmy się jak można zmienić styl linii:

def rysuj_linie(linia):
    kontekst.move_to(x1, y1 + odstep * linia)
    kontekst.line_to(x2, y2 + odstep * linia)
    kontekst.stroke()

plik = 'rysunek_11.png'
szerokosc = 300
wysokosc = 250
powierzchnia = cairo.ImageSurface(cairo.FORMAT_RGB24, szerokosc, wysokosc)
kontekst = cairo.Context(powierzchnia)
kontekst.set_source_rgb(0.9, 0.9, 0.9)
kontekst.paint()
x1, y1 = 50, 20
x2, y2 = 250, 20
# Odstęp między liniami
odstep = 40
grubosc = 10

kontekst.set_line_width(grubosc)
kontekst.set_source_rgb(0, 0, 0)

# Linia
rysuj_linie(0)

# Linia złożona z odcinków widocznych i niewidocznych
# o tej samej długości, równej grubości linii
kontekst.set_dash([grubosc])
rysuj_linie(1)

# Można oczywiście podać konkretną liczbę
# określającą długość odcinków widocznych i niewidocznych
kontekst.set_dash([30])
rysuj_linie(2)

# Cześć niewidoczna linii 2 razy dłuższa niż widocznej
rysuj_linie(3)

# Część widoczna 2 * dłuższa niż grubość, niewidoczna = grubość
# widoczna = grubość, niewidoczna = grubość
kontekst.set_dash([grubosc * 2, grubosc, grubosc, grubosc])
rysuj_linie(4)

# Na zmianę części widoczne i niewidoczne, o długości:
# grubość, grubość, grubość * 2 
kontekst.set_dash([grubosc, grubosc, grubosc * 2])
rysuj_linie(5)

powierzchnia.write_to_png(plik)

Rysunek

Teraz najpierw narysujemy takie same linie jak powyżej, ale z dodanymi zaokrąglonymi końcówkami i poniżej nich kilka przykładowych linii w stylach złożonych z kropek. Przeanalizuj kod.

def rysuj_linie(linia):
    kontekst.move_to(x1, y1 + odstep * linia)
    kontekst.line_to(x2, y2 + odstep * linia)
    kontekst.stroke()
    
plik = 'rysunek_12.png'
szerokosc = 300
wysokosc = 250
powierzchnia = cairo.ImageSurface(cairo.FORMAT_RGB24, szerokosc, wysokosc)
kontekst = cairo.Context(powierzchnia)
kontekst.set_source_rgb(0.9, 0.9, 0.9)
kontekst.paint()
x1, y1 = 50, 20
x2, y2 = 250, 20
# Odstęp między liniami
odstep = 20
grubosc = 10

kontekst.set_line_width(grubosc)
kontekst.set_source_rgb(0, 0, 0)
# Ustawiamy zaokrąglone zakończenia dla linii i ich odcinków
kontekst.set_line_cap(cairo.LINE_CAP_ROUND)

# Linia
rysuj_linie(0)

# Linia złożona z odcinków widocznych i niewidocznych
# o tej samej długości, równej grubości linii
kontekst.set_dash([grubosc])
rysuj_linie(1)

# Można oczywiście podać konkretną liczbę
# określającą długość odcinków widocznych i niewidocznych
kontekst.set_dash([30])
rysuj_linie(2)

# Cześć niewidoczna linii 2 razy dłuższa niż widocznej
kontekst.set_dash([grubosc])
rysuj_linie(3)

# Część widoczna 2 * dłuższa niż grubość, niewidoczna = grubość
# widoczna = grubość, niewidoczna = grubość
kontekst.set_dash([grubosc * 2, grubosc, grubosc, grubosc])
rysuj_linie(4)

# Na zmianę części widoczne i niewidoczne, o długości:
# grubość, grubość, grubość * 2 
kontekst.set_dash([grubosc, grubosc, grubosc * 2])
rysuj_linie(5)

# Długość = 0 (+zakończenia), grubość
kontekst.set_dash([0, grubosc])
rysuj_linie(7)

# Długość = 0 (+zakończenia), grubość
kontekst.set_dash([0, grubosc * 2])
rysuj_linie(8)

# Długość = 0 (+zakończenia), grubość
# Inna grubość linii
grubosc = 5
kontekst.set_line_width(grubosc)
kontekst.set_dash([0, grubosc * 2])
rysuj_linie(9)

# Dwie powyższe linie, narysowane w tym samym miejscu
grubosc = 5
kontekst.set_line_width(grubosc)
kontekst.set_dash([0, grubosc * 2])
rysuj_linie(10)
grubosc = 10
kontekst.set_line_width(grubosc)
kontekst.set_dash([0, grubosc * 2])
rysuj_linie(10)

powierzchnia.write_to_png(plik)

Rysunek

Styl linii można zastosować także do obrysów figur:

plik = 'rysunek_13.png'
szerokosc = 600
wysokosc = 400
powierzchnia = cairo.ImageSurface(cairo.FORMAT_RGB24, szerokosc, wysokosc)
kontekst = cairo.Context(powierzchnia)
kontekst.set_source_rgb(0.8, 0.8, 0.8)
kontekst.paint()
kontekst.rectangle(szerokosc/4, 
                   wysokosc/4,
                   szerokosc - szerokosc/4*2, 
                   wysokosc -  wysokosc/4*2)
kontekst.set_source_rgb(1, 0.7, 0)
kontekst.fill_preserve()
# Obrys
kontekst.set_line_cap(cairo.LINE_CAP_ROUND)
kontekst.set_source_rgb(0, 0, 0)
grubosc = 10
kontekst.set_line_width(grubosc)
kontekst.set_dash([0, grubosc * 2])
kontekst.stroke()
powierzchnia.write_to_png(plik)

Rysunek

Struktury fraktalopodobne, rekurencja i animacja

Komputerowe generowanie grafiki pozwala w stosunkowo łatwy sposób stworzyć złożone obrazy w oparciu o dość proste reguły. Utwórzmy strukturę fraktalopodobną złożoną z kółek. Przy okazji poznamy zastosowanie rekurencji. Polega ona na tym, że dana funkcja odwołuje się do siebie samej. Aby zabezpieczyć program przed zapętleniem należy umieścić w kodzie jakieś zabezpieczenie, które przerwie ten proces. W poniższym przypadku mamy funkcję rysuj_kolo(), która przyjmuje trzy argumenty:

  • r - promień koła
  • x, y - współrzędne położenia koła

Po wywołaniu funkcji, rysuje ona koło i jeśli r > 5, to dwukrotnie wywołuje siebie samą ale zmniejszając promień koła o połowę oraz przesuwając położenie koła (o długość r) w prawo, przy pierwszym wywołaniu, i w lewo przy drugim wywołaniu.

plik = 'fraktal.png'
szerokosc = 800
wysokosc = 400
powierzchnia = cairo.ImageSurface(cairo.FORMAT_RGB24, szerokosc, wysokosc)
kontekst = cairo.Context(powierzchnia)
kontekst.set_source_rgb(1, 1, 1)
kontekst.paint()

def rysuj_kolo(r, x, y):
    kontekst.arc(x, y, r, 0, math.pi*2)
    kontekst.set_line_width(1)
    kontekst.set_source_rgba(0, 0.4, 0, 0.3)
    kontekst.fill_preserve()
    kontekst.set_source_rgba(0, 0.4, 0, 0.5)
    kontekst.stroke()
    if r > 5:
        rysuj_kolo(r / 2, x + r, y)
        rysuj_kolo(r / 2, x - r, y)

x, y = 400, 200
r = 180 
rysuj_kolo(r, x, y)

powierzchnia.write_to_png(plik)

Rysunek

W zrozumieniu jak działa rekurencja w powyższym kodzie, pomoże zrozumieć animacja pokazująca kolejne etapy tworzenia struktury fraktalopodobnej. Połączymy siły Pycairo i pillow aby wygenerować animację w formacie gif. Nieco zmodyfikujemy powyższy kod generujący obrazek, tak aby zapisywał osobny plik dla każdego narysowanego koła. Nazwy kolejnych wygenerowanych plików będziemy zapisywać w liście obrazki. Następnie wczytamy kolejno pliki z użyciem pillow i dodamy je do kolejnej listy klatki, której użyjemy w funkcji save, która wygeneruje tym razem animowany gif:

from PIL import Image, ImageDraw
import cairo

szerokosc = 800
wysokosc = 400

powierzchnia = cairo.ImageSurface(cairo.FORMAT_RGB24, szerokosc, wysokosc)
kontekst = cairo.Context(powierzchnia)
kontekst.set_source_rgb(1, 1, 1)
kontekst.paint()
obrazki = []
# Katalog na klatki - obrazki z kolejnymi etapami
# tworzenia obrazu
katalog_klatek='klatki'

# Utworzenie pliku na klatki, jeśli nie istnieje
if os.path.exists(katalog_klatek):
    print(f'katalog:{katalog_klatek} istnieje')
else:
    print(f'Tworzę {katalog_klatek}')
    os.makedirs(katalog_klatek)

def rysuj_kolo(r, x, y, n):
    plik = f'{katalog_klatek}/fraktal_{n}.png'
    obrazki.append(plik)
    n += 1

    kontekst.arc(x, y, r, 0, math.pi*2)
    kontekst.set_line_width(1)
    kontekst.set_source_rgba(0, 0.4, 0, 0.3)
    kontekst.fill_preserve()
    kontekst.set_source_rgba(0, 0.4, 0, 0.5)
    kontekst.stroke()
    powierzchnia.write_to_png(plik)
    if r > 5:
        n = rysuj_kolo(r / 2, x + r, y, n)
        n = rysuj_kolo(r / 2, x - r, y, n)
    return n

n = 1
x, y = 400, 200
r = 180 
rysuj_kolo(r, x, y, n)

klatki = []
for plik in obrazki:
    obrazek = Image.open(plik)
    klatki.append(obrazek)
        
klatki[0].save('fraktal.gif',
               save_all=True, append_images=klatki[1:], duration=100, loop=0)

print("Koniec")
Koniec

Rysunek

Last updated on 10 May 2021
Published on 10 May 2021