Bywacie czasem nieszczęśliwi? Co wtedy robicie? Pijecie? Palicie? Snujecie się po zaułkach? Programujecie?
Ja robię wszystko powyżej, naraz. W ramach nieustającej akcji „im więcej myślisz, tym bardziej nie chcesz” znalazłem się w momencie, gdzie potrzebowałem dnia ucieczki od zgiełku zwykłego dnia. Zamiast iść do biura poszedłem zwiedzać, a podczas zwiedzania wpadł mi do głowy pomysł na zajęcie umysłu. Na Twitterze jest sobie taki bot, który produkuje — zabawnie nazywane — palety kolorów. Nazywa się toto colorschemer.
Oto przykład:

Myślę sobie: a gdyby napisał bota, który potrafi wyekstrahować kolory z załączanych na Twitterze obrazków tym samym doprowadzając do SKYNET-u? Mógłbym generować z nich arkusze stylów z deklaracjami, palety do GIMP-a, czy to tam.
Brzmi jak marnowanie czasu!
Konsumujemy
Zanim zaczniemy naszą podróż po obrazku musimy zamienić plik znajdujący się na dysku w kupkę informacji. Dla Pythona dostępna jest biblioteka Pillow zawierająca w sobie wszystkie potrzebne funkcje. Użyjemy jej aby uzyskać piksele, kolory oraz rozmiar obrazka.
Na boku Bardziej zaawansowani czytelnicy mogą się skrzywić, że piszę funkcje zawierające tylko return
— normalnie używam takich funkcji aby ułatwić sobie odpluskwianie i obsługę błędów. W tym przykładzie nie chcę dodatkowo zaciemniać, więc pozostawiłem tak, jak jest, no i funkcje mają bardziej zrozumiałe nazwy niż oryginalne metody.
def load_image(filename: str):
return Image.open(filename)
def get_pixels(image: Image):
return image.load()
def get_bounds(image: Image):
return image.getbbox()
I tak, w kolejności: load_image
wczytuje obrazek z dysku, get_pixels
zwraca piksele na których możemy dokonywać analizy, get_bounds
zwraca cztery wartości, które określają obszar zajmowany przez obraz.
Kolory na ekranie
Ekran komputera możemy reprezentować jako dwuwymiarową płaszczyznę której każdy punkt reprezentuje para wartości X i Y. X odpowiada za oś lewo-prawo, a Y, odpowiednia góra-dół. Pozycje zaczynamy liczyć od lewa w prawo i od góry w dół. To znaczy, że miara koordynat (0, 0)
to piksel znajdujący się w górnym, lewym rogu.
Jeśli obraz ma rozdzielczość 1980x1080 to jego prawy dolny róg znajduje się na (1979, 1079)
gdyż liczymy od zera.
Pod każdym z tych punktów kryje się święta trójca, Zielony, Czerwony i Niebieski, składowe kolorów w systemie RBG.
Kolory w RBG są reprezentowane przez bajt, który to bajt może przechowywać wartości numeryczne od 0 do 255. Czyli jeden piksel to trzy bajty informacji przybite do jakiejś pozycji na ekranie. Gdybyśmy chcieli sobie wyobrazić bardzo mały kwadrat, który ma bok składający się z dwóch pikseli, to moglibyśmy zapisać te dane w następujący sposób:
square = (
(255, 0, 0), (0, 255, 0),
(0, 0, 255), (128, 128, 128)
)

Skoro wiemy w jaki sposób otrzymamy dane, zobaczmy, z czym nam przyjdzie pracować.
Pierwszy krok: w którą stronę ciąć

Wszystkie obrazki zamieszczane na koncie przychodzą w dwóch formatach. Kolory są ułożone góra/dół lub lewo/prawo. Wykrycie typu obrazka będzie naszym pierwszym zadaniem. Utworzymy sobie typ wyliczeniowy (enum) żeby było nam łatwiej czytać kod.
Nazwałem mój enum Gravity
, bo wydawało mi się, że to dobra, wiele mówiąca nazwa. „W którą stronę «spadają» kolory. Kiedy usiadłem do tego tekstu stało się oczywiste, że dużo lepszą nazwą byłoby Orientation
. Udowodniłem tym samym teorię, że nazywanie rzeczy w programowaniu jest sztuką trudną.
class Gravity(Enum):
VERTICAL = 0
HORIZONTAL = 1
Zastanówmy się, jak wykryć, czy powinniśmy badać kolory idąc z góry w dół, czy też idąc z lewa w prawo.
Jeśli weźmiemy piksel znajdujący się pod koordynatami (0, 0)
, a potem weźmiemy piksel znajdujący się w przeciwległym rogu (0, szerokość obrazka)
to jeśli są one takie same, znaczy, że układ obrazka jest góra/dół, bo cała pierwsza linia jest jednego koloru! Proste! W przeciwnym przypadku obrazek zorientowany jest lewo/prawo, gdyż wiemy, że kolor w lewym rogu jest inny niż ten w prawym.

Zrobimy z tego funkcję, która zwróci nam typ „grawitacji”.
def detect_gravity(pixels, bounds):
max_width = bounds[2]
max_height = bounds[3]
top_left_corner = pixels[0, 0]
top_right_corner = pixels[0, max_width - 1]
bottom_left_corner = pixels[0, max_height - 1]
if top_left_corner == top_right_corner:
return Gravity.HORIZONTAL
return Gravity.VERTICAL
Wszystkie te kolory
OK, wiemy już jakiego typu obrazek wczytaliśmy. Teraz naszym zadaniem jest wybranie unikalnych kolorów. Zrobimy to idąc po linii i zapisując znalezione kolory. Powinno być całkiem prosto, wystarczy wybrać kierunek, zwiększać licznik o jeden i zapisywać do tablicy rezultatów unikalne wartości RGB.
Zerknijmy na kod.
def extract_distinct_colors(pixels, gravity, bounds):
idx = 0
if gravity == Gravity.VERTICAL:
max_travel = bounds[3]
else:
max_travel = bounds[2]
distinct_colors = []
while idx < max_travel:
if gravity == Gravity.VERTICAL:
pixel = pixels[0, idx]
else:
pixel = pixels[idx, 0]
idx += 1
if pixel not in distinct_colors:
distinct_colors.append(pixel)
return distinct_colors
Ustawiamy indeks idx
na zero, ustawiamy rozmiar boku, którym będziemy podróżowali zależnie od «grawitacji», tworzymy pustą tablicę na rezultaty. Następnie kręcimy się aż do wyczerpania boku i sprawdzamy, czy właśnie odnaleziony piksel (dokładniej, trójca jego kolorów) jest już w distinct_colors
, jeśli nie ma, dodajemy. Po zakończeniu operacji zwracamy zawartość tablicy.
Odpaliłem kod i wszystko działało, może nawet za dobrze, bo otrzymałem piętnaście kolorów, gdy spodziewałem się trzech. Dałem się nabrać swoim starym oczom, ale też udało mi się zapomnieć o faktach dotyczących obrazków w Internecie: kompresji. Jeśli popatrzymy na ofiarę naszej inspecji będziemy jasno widzieć (dosłownie) trudne do wyodrębnienia wahania w kolorze. Wahania, które ten naiwny kod, który szuka tylko unikalności, dokłada do listy. I dobrze robi, bo są to unikalne kolory, ale nie to chcieliśmy uzyskać.

Co teraz? Podglądanie obrazka pod lupą upewniło mnie, że te przekłamania w kolorze występują w niewielu miejscach, głównie tam, gdzie spotykają się dwa różne kolory. Rozsądnym sposobem byłoby liczenie ilości wystąpień. To pozwoli nam odrzucić sieroty po kompresji. Zmodyfikowałem więc kod w następujący sposób: dodałem occurance_counter
, który zawiera liczniki wystąpień danego koloru. W bloku try…except
sprawdzam w jakim miejscu w liście znajduje się obecnie odkryty kolor, a jeśli się nie znajduje (co podniesie wyjątek ValueError
na metodzie .index()
) znaczy, że widzimy go pierwszy raz. Tym razem z funkcji zwracamy dwie wartości: listę kolorów oraz ich liczniki.
def extract_distinct_colors(pixels, gravity, bounds):
idx = 0
if gravity == Gravity.VERTICAL:
max_travel = bounds[3]
else:
max_travel = bounds[2]
distinct_colors = []
occurance_counter = {}
while idx < max_travel:
if gravity == Gravity.VERTICAL:
pixel = pixels[0, idx]
else:
pixel = pixels[idx, 0]
idx += 1
try:
found_at_index = distinct_colors.index(pixel)
occurance_counter[ found_at_index ] = occurance_counter[ found_at_index ] + 1
except ValueError as e:
distinct_colors.append(pixel)
occurance_counter [ distinct_colors.index(pixel) ] = 1
return (distinct_colors, occurance_counter)
Teraz muszę dodać tylko funkcję, która użyje obu tych informacji i zwróci mi n najczęściej występujących kolorów. Muszę przyznać, że byłem tu już trochę zmęczony, siedziałem na ławce w ciemnym parku, offline, paląc papierosy. Dlatego funkcja filter_top_entries
nie jest może najbardziej przyjazna dla oczu początkujących, dlatego rozłożę ją może na czynniki pierwsze.
def filter_top_entries(count, distinct_colors, occurance_counter):
filtered = sorted(occurance_counter.items(), key=lambda x: x[1], reverse=True)[0:count]
return [distinct_colors[ x[0] ] for x in filtered]
Pierwszym paraemetrem jest liczba elementów, które chcemy uzyskać, dwa pozostałe to produkt extract_distinct_colors
.
occurance_counter
zawiera dane w następującym formacie:
{
'0': 12,
'4': 1,
'2': 34,
[…]
}
Indeksem w tym słowniku jest pozycja koloru w distinct_colors
, a jego wartością jest liczba wystąpień. W Pythonie słownik posiada metodę .items()
, która zwraca pary elementów, czyli gdybyśmy użyli jej na tym przykładowym kodzie, otrzymalibyśmy ( ('0', 12), ('4', 1), ('2', 34))
. To właśnie przekazujemy do sortowania. Ale sorted()
nie wie jak posortować dwuelementową listę. Dlatego też w parametrze key
podajemy kawałek kodu, który wskaże jaki element z tej listy jest parametrem, który nalezy posortować. Pod indeksem 0 znajduje się pozycja koloru, pod indeksem 1 znajduje się liczba wystąpień. x: x[1]
znaczy „do sortowania użyj ilości wystąpień, które znajdziesz w liście na pozycji pierwszej.
Ponieważ sorted()
domyślnie sortuje od najmniejszej do największej musimy też poprosić o odwrócenie tego zachowania, gdyż szukamy najczęściej występujących kolorów. Do tego właśnie służy parametr reversed=True
.
Produktem sorted()
jest lista, więc możemy na niej użyć pythonowej składni do przycinania list, w tym wypadku [0:count] zwróci nam tylko fragment posortowanej listy do ilości podanej w pierwszym parametrze.
I to jest pierwsza linia. ;-)
Python posiada wygodną składnię do „kompresowania” wyrażeń normalnie zamykanych w for
, to właśnie robi druga linia. Można ją przeczytać „dla każdego elementu w filtered
, które uzyskaliśmy z sorted()
weź element pod indeksem zero, który jest indeksem koloru na liście «unikalności» i zwróć to wszystko jako tablicę”
Alternatywnie możnaby to zapisać jako:
results = []
for x in filtered:
results.append(distinct_colors[ x[0] ])
I już! Teraz powinniśmy mieć możliwość uzyskania trzech najważniejszych kolorów!
Składanie tego wszystkiego do kupy
Teraz możemy przetestować wszystko razem. Normalnie nie pisze się kodu testującego bezpośrednio w bibliotece, ale… wiecie, jak to jest. Zrobiłem sobie listę obrazków skradzionych z Twittera, wsadziłem je do listy i dla każdej z nich wywołałem wszystkie, dyskutowane wcześniej, funkcje.
Dopisałem jeszcze to_hex_expression
, które zamienia RGB w znany nam dobrze format heksadecymalny używany w programach graficznych oraz CSS.
test_cases = [
'test-cases/Dk82gZuVsAAcGho.jpg',
'test-cases/Dk666IVUUAALxRx.jpg',
'test-cases/Dk7kG4qU0AAJ5RL.jpg',
'test-cases/Dk8oxdUVAAY6-JY.jpg',
'test-cases/Dk9EPPVV4AA0obD.jpg',
]
from writers import to_hex_expression
for test in test_cases:
print()
image = load_image(test)
bounds = get_bounds(image)
pixels = get_pixels(image)
gravity = detect_gravity(pixels, bounds)
distinct_colors, occurance_counter = extract_distinct_colors(pixels, gravity, bounds)
colors = filter_top_entries(3, distinct_colors, occurance_counter)
for color in colors:
print(to_hex_expression(color))
A oto rezultat:
test-cases/Dk82gZuVsAAcGho.jpg Gravity.VERTICAL
#446bae
#a10499
#35530b
test-cases/Dk666IVUUAALxRx.jpg Gravity.HORIZONTAL
#fcf779
#77ab56
#1fa874
test-cases/Dk7kG4qU0AAJ5RL.jpg Gravity.VERTICAL
#9eff00
#ff5c01
#cc406f
test-cases/Dk8oxdUVAAY6-JY.jpg Gravity.HORIZONTAL
#7f8f4e
#aca588
#ff6bb5
test-cases/Dk9EPPVV4AA0obD.jpg Gravity.VERTICAL
#24ff29
#1fb47a
#4a5d98
Trzy kolory „wyssane” z obrazka. Pełen sukces, dzień zmarnowany, wszyscy są szczęśliwi. Niestety, nie dotarłem do momentu w którym nauczyłbym kod łażenia „osobiście” na Twitter i podkradania obrazków, gdyż skończył się mi dzień.
Jeśli ktoś z czytelników uważa, że jest sens napisać drugą cześć, gdzie wpadlibyśmy grabić via API to można mi zostawić e-mail.
Przepraszam też jeśli ten tekst był zbyt chaotyczny i/lub bezużyteczny. Próbuję pobić prokrastynację przez brute force. Repozytorium z kodem jest dostepne na bitbucket, bronikowski/kaleidoscope.