Niecodzienna lekcja kodowania - jak komputery interpretują obrazki?

Intro

Pewnego dnia pojawiła się w mojej głowie idea - co by się stało gdyby zakodować obrazek w czystym HTML-u? Intuicyjnie czułem, że to mało optymalne rozwiązanie, bo formaty plików graficznych z reguły są wyżyłowane - tzn. wyciągnięto z nich maksimum jakie było można. Pliki graficzne używają zaawansowanych algorytmów kompresji - tak by obrazek zajmował jak najmniej miejsca (jeśli takie jest wymaganie, a w świecie internetu można założyć, że jest ono prawdziwie dla większości przypadków użycia). Naturalnym rozszerzeniem plików graficznych są pliki video, gdzie w dobie rozwiązań streamingowych takich jak netflix, disney+ i inne wymaganie przesyłu jak najwyższej jakości jak najtaniej i najszybciej staje się jeszcze bardziej istotne.

Z drugiej strony mój pomysł niesie ogromny walor edukacyjny i o tym będzie ten post: spórbujesz przetworzyć obrazek w HTML, ale bez używania <img> tagów. Zaczynamy.

Jak komputer interpretuje obrazek? Czym on jest?

Obrazek to plik graficzny, w odróżnieniu do plików teskstowych jego zawartość jest nieczytelna dla człowieka - ot uporządkowany w jakiś sposób zbiór bajtów (dla uszczegółowienia, niektóre pliki tekstowe otwarte bezpośrednio - jak .doc też są mało czytelne dla człowieka). Pokażę Wam przykłady:

Pierwsze linie pliku JPEG:

cat sample.jpeg
 acspAPPLAPPL���-appl���%M8�����hop 3.08BIM8BIM%��ُ��  ���B~��4ICC_PROFILE$applmntrRGB XYZ �
desc�ecprtd#wtpt�rXYZ�gXYZ�bXYZ�rTRC� chad�,bTRC� gTRC� desc
                                                            Display P3textCopyright Apple Inc., 2017XYZ �Q�XYZ ��=�����XYZ J��7
�XYZ (8
Y�     ȹparaff�
[sf32
     B����&�������������n�e"��  

���}!1AQa"q2��#B��R��$3br�      
%&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz���������������������������������������������������������������������������

���w!1AQaq"2B����       #3R�br�
$4�%�&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz��������������������������������������������������������������������������C
...

Widać nagłówek pliku, z którego można wyciągnąć informacje, ale główna zawartość pliku jest nieczytelna.

Dla porównania - plik HTML:

cat sample.html
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"><html><head>
<META http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title></title><meta name="generator" content="Altova StyleVision Basic Edition 2018 sp1 (http://www.altova.com)"><meta http-equiv="X-UA-Compatible" content="IE=9"><style type="text/css"><!--table {border:none;width:100%;border-collapse:collapse;}
    tr.naglowek {background-color:#CCCCCC;vertical-align:top;text-align:center;font-size:80%;font-family:sans-serif;font-weight:bold;text-decoration:none;}
...

Jest dla człowieka zrozumiała - o ile potrafi rozmawiać w dialekcie HTML.

A tu plik docx:

cat sample.docx                              
B"�Rword/numbering.xml�ZI��0|A�`�nk�a�9d0A����-�E )y��rK�y[^j�2�#;t�IPwW5�.w��W�G9�1��2F��,B4�����               h0�00�P��wwfd��
                                                                                                                               *|F"eH���R�\3N�T�<6      �/Y:I�D+��ܚ�e͌��FƩ_S�   
9l-
�Q��Pч*�2�V���䁅�T�M�:�"A�h�ȵl*�4$��!r���Mڥ[��F�3�U�
��          ,(ZD�#�lNB�-M��#���D��/���
  ���J}F+���)����OQ'1(��x+�k(�p��k0
                                   _`���b��Nr>b��9 ;���>Y�:�˷�p���βt'w���o�=����j�����KFFo#�L��c�B�VQ�K�.��e�LIeCYe�    ���?�~��Oa�p]��_y�@4R�"sG-����[ݝYE����5lTm���L�e��a�[
                                             ���al��e����}�4�=�ew�9�E/�x�(M��i��(�s���A�6����A�6����(m��j
                                                                                                         ���O�u��j��~��V���[����s����0��o��j���o=��o��j��~��V��-���>K��=0݃1Ͳ�

Pliki graficzne mają właśnie w sobie głównie te znaczki, których nie idzie zinterpretować jedynie na nie patrząc (może są ludzie, którzy to potrafią, ale nie znam takich).

Zawartość takiego pliku zwykle jest skompresowana (zależy twórcom tutaj na jak najmniejszym rozmiarze pliku z reguły). Kompresja może być stratna lub bezstratna. Stratna oznacza, że w momencie kompresji tracimy część informacji, i nie jesteśmy w stanie odtworzyć oryginalnego pliku. Bestratna z kolei znaczy, że możemy odtworzyć oryginalne dane bez żadnej straty. Dla fanów muzyki koncept kompresji stratnej/bestratnej powinien być bardzo bliski.

Zatem czym jest plik po dekompresji?

By odczytać i w konsekwencji zobaczyć plik graficzny musimy go otworzyć i dokonać dekompresji, to się dzieje samo w większości systemów operacyjnych po dwukrotnym kliknięciu na plik. W tym momencie plik można zinterpretować jako dwuwymiarową macierz, gdzie jeden wymiar to szerokość obrazka, drugi to wysokość obrazka, a zawartość komórek to piksel.

Teraz trochę niespodzianka, bo różne pliki graficzne będą niosły różną informację (metadane) na temat piksela. Większość przechowa w jakiejś formie kolor, niektóre tzw. kanał alpha (przezroczystość) - format PNG wspiera przezroczystość.

Kolor często przechowywany jest w formacie RGB - czyli trzy liczby całkowite oznaczjące ile jest koloru czerwonego (R - ang. red), zielonego (G - ang. green) i niebieskiego (B - ang. blue). Kanał alpha plus kolor to format RGBA, gdzie kolejna liczba całkowita przechowuje wartość przezroczystości.

Wartości liczb całkowitych mieszczą się w przedziale 0 - 255, stąd możliwa liczba kolorow to 255 * 255 * 255 = 16581375 w przestrzeni RGB. Warto wiedzieć, że istnieją inne przestrzenie kolorów: CMYK, HSL, HSV.

Teraz możemy sobie wyobrazić i narysować obrazek 2x2 piksele:

    [
        [[(255, 0, 0)], [(0, 255, 0)]],
        [[(0, 0, 255)], [(0, 0, 0)]]
    ]

Czy widzisz już ten obrazek?

Powiększyłem rozmiar piksela, by obrazek był bardziej widoczny.

Zatem otrzymaliśmy obrazek składający się z czterech pikseli w dwóch kolumnach po dwa wiersze (macierz 2x2). Pierwszy piksel jest czerwony, drugi zielony, trzeci niebieski, a ostatni czarny.

(255, 0, 0) - pierwszy element odpowiada wartości koloru czerwonego (R), "zaświeciłem" go na maksimum, nie zmieniając reszty. Podobnie zrobiłem z innymi pikselami. A jak uzyskać kolor biały? Wystarczy zrobić tak: (255, 255, 255). A żółty? Żółty to miks czerwonego i zielonego: (255, 255, 0). I tak dalej i dalej z całą gamą odcieni.

Tyle informacji wystarczy by przejść do kolejnego etapu.

Programowanie skryptu i rozwiązanie postawionego problemu

Problem jest następujący:

  • Odczytać podstawowe informacje z pliku graficznego - piksele i odpowiadające im kolory.
  • Zapisać plik graficzny w HTML - używając innych tagów niż img.

Zwykle w pliku HTML obrazki obsługuje się tak:

<img src="my_image.png">

Dzięki temu, przeglądarka wie jak wczytać ten plik oraz jak go wyświetlić. To, co ja bym chciał zrobić to zakodować obrazek na tagach div i span. Czyli obrazek będzie wyglądał jakoś tak:

<div><span>Pierwszy Piksel</span><span>Drugi Piksel</span></div>  // pojedynczy wiersz pikseli

Na pierwszy rzut oka widać, że taki format zapisu będzie więcej niż nieefektywny, bo na każdy piksel potrzeba tagu otwierającego i zamykającego plus jeszcze informacji o kolorze tła. Dla obrazka 1000x1000 pixeli sama liczba tagów będzie oscylować w okolicy 2.02 miliona. Niemniej napisanie skryptu, który potrafi konwertować obrazki w HTMLa brzmi jak dużo zabawy - jedziemy.

Do wczytania pliku użyjemy biblioteki PIL - Python Image Library.

from PIL import Image
im = Image.open("sample.png")

W tym momencie mamy wczytan plik sample.png do skryptu, obrazek wygląda tak (podziękowana dla: Lukas Kloeppel):

W idealnym świecie, odczytałbym piksel po piskelu i wartość koloru dla każdego z nich i zmapował w plik HTML, ale pomyślałem sobie od razu, że można zrobić kod bardziej generycznie i stworzyć parametr, który określi podstawowy blok. Blok będzie niczym innym jak kwadratem o zadanej długości boku w pikselach - 10x10, 50x50 itd.

Kawałek kodu, który skanuje plik i wyciąga średnią wartość koloru dla danego bloku wygląda tak:

width, height = im.size
box_size = 10
new_image = []

for i in range(0, height, box_size):
    row = []
    for j in range(0, width, box_size):
        cell = im.crop((j, i, j + box_size, i + box_size))
        avg_color = get_avg_color(cell)
        row.append(avg_color)
    new_image.append(row)

# a tu funkcja dla obliczania średniego koloru (wklej ją przed powyższym blokiem)

def get_avg_color(im):
    red = 0
    blue = 0
    green = 0
    pixels_count = 0
    for pixel in im.getdata():
        red += pixel[0]
        green += pixel[1]
        blue += pixel[2]
        pixels_count += 1
    avg_red = int(red / pixels_count)
    avg_green = int(green / pixels_count)
    avg_blue = int(blue / pixels_count)
    return (avg_red, avg_green, avg_blue)

W tym momencie jeszcze nic nie dzieję się z HTML. To co tu robimy to na podstawie pewnych parametrów tworzymy nowy obrazek, by ten obrazek zapisać (lub pokazać) wystarczy zrobić:

import numpy as np

narr = np.array(new_image)
show_im = Image.fromarray(narr.astype(np.uint8), "RGB")
show_im.show()  # pokazuje
show_im.save(f"sample{box_size}.png")  # zapisuje

Teraz kilka przykładów dla różnych wartości rozmiaru bloku:

  • Rozmiar bloku: 5 pikseli
  • Rozmiar bloku: 10 pikseli
  • Rozmiar bloku: 50 pikseli

Całkiem nieźle. To, co się tutaj dzieje to też skalowanie, przy bloku o rozmiarze 5 pikseli nowy obrazek ma wymiar 200x200 (1000 / 5), przy 10 pikselach - 100x100 (1000 / 10) itd. To, co robi skrypt powyżej to bierze odpowiedni blok, liczy średni kolor i zapisuje go jako informacje dla jednego nowego piksela. W pewnym sensie zaimplementowaliśmy stratną kompresję :)

Jedyne co nam teraz pozostało, to zapisanie tych informacji w HTML. Będziemy potrzebować prosty pliku szablonu:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>My Website</title>
    <style>
        {styles}
    </style>
  </head>
  <body>
    {divs}
  </body>
</html>

Zapisz go sobie pod index.tmpl

W skrypcie później zamienimy {styles} i {divs} na takie wygenerowane przez kod.

Parę modyfikacji w skrypcie:

def rgb_to_hex(rgb):
    return '#%02x%02x%02x' % rgb

width, height = im.size
box_size = 10
divs = []

for i in range(0, height, box_size):
    row = []
    spans = []
    for j in range(0, width, box_size):
        cell = im.crop((j, i, j + box_size, i + box_size))
        avg_color = get_avg_color(cell)
        spans.append(f'<span style="background-color: {rgb_to_hex(avg_color)};"></span>')
    divs.append("<div>" + f"{''.join(spans)}" + "</div>")

styles = [
    "body {margin: 0;}",
    "span {width: " + str(box_size) + "px; height: " + str(box_size) + "px; display: inline-block;}",
    "div {margin: 0; padding: 0; height: " + str(box_size) + "px;}",
    "span {margin: 0; padding: 0;}"
]

with open("index.tmpl", "r") as f:
    template = f.read()

index = template.format(
    styles="\n".join(styles),
    divs="\n".join(divs)
)

with open("index.html", "w") as f:
    f.write(index)

Zwróć uwagę na metodę rgb_to_hex, która zmienia format zapisu koloru, przeglądarki oczekują zapisu szesnatkowego, stąd potrzebowałem konwersji.

Zwróć także uwagę na styles tam mamy coś takiego, że rozmiar podstawowego bloku na wyjściu jest przeskalowany, tj. jakby jeden piksel ma wymiar bloc_size x block_size dla zabawy możesz tam wstawić 1px na sztywno - wtedy obrazki będą przeskalowane.

I gotowe!

Tak wygląda obrazek z przeglądarki dla bloku o rozmiarze 10x10, każdy pojedynczy piksel ma rozmiar 10x10 a cały obrazek rozmiar oryginału 1000x1000. Widać pikselozę, ale gdyby zmniejszyć rozmair piksela na 1x1 mniejszy wyglądałby tak jak ten zaprezentowany wyżej.

Uruchom skrypt i otwórz powstały index.html w przeglądarce. Zrób parę testów - z różną wielkością podstawowego bloku, dla bloku o wielkości 1 piksel, ładowanie obrazka w przeglądarce trwa wieki, a plik html waży 50MB w porównaniu do 2MB oryginalnego PNG.

A obrazek w HTML wygląda tak:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>My Website</title>
    <style>
        body {margin: 0;}
span {width: 10px; height: 10px; display: inline-block;}
div {margin: 0; padding: 0; height: 10px;}
span {margin: 0; padding: 0;}
    </style>
  </head>
  <body>
    <div><span style="background-color: #2ea4f2;"></span><span style="background-color: #2ea3f2;"></span><span style="background-color: #2ca3f2;"></span><span style="background-color: #27a2f1;"></span><span style="background-color: #22a1f1;"></span><span style="background-color: #1da1f1;"></span><span style="background-color: #21a1f1;"></span><span style="background-color: #27a2f1;"></span><span style="background-color: #2ba2f1;"></span><span style="background-color: #1da0f0;"></span><span style="background-color: #0d9ef0;"></span><span style="background-color: #059cef;"></span><span style="background-color: #0199ee;"></span>
...

Co już można spróbować dekodować w pamięci :)

Materiały

Skrypt w całości

convert.py

from PIL import Image
import numpy as np

# Fun!

# Find average color
def get_avg_color(im):
    red = 0
    blue = 0
    green = 0
    pixels_count = 0
    for pixel in im.getdata():
        red += pixel[0]
        green += pixel[1]
        blue += pixel[2]
        pixels_count += 1
    avg_red = int(red / pixels_count)
    avg_green = int(green / pixels_count)
    avg_blue = int(blue / pixels_count)
    return (avg_red, avg_green, avg_blue)

def rgb_to_hex(rgb):
    return '#%02x%02x%02x' % rgb

im = Image.open("sample.png")
width, height = im.size
box_size = 10
divs = []

# new_image = []
for i in range(0, height, box_size):
    # row = []
    spans = []
    for j in range(0, width, box_size):
        cell = im.crop((j, i, j + box_size, i + box_size))
        avg_color = get_avg_color(cell)
        spans.append(f'<span style="background-color: {rgb_to_hex(avg_color)};"></span>')
    divs.append("<div>" + f"{''.join(spans)}" + "</div>")

im.close()

# now more fun!
styles = [
    "body {margin: 0;}",
    "span {width: " + str(box_size) + "px; height: " + str(box_size) + "px; display: inline-block;}",
    "div {margin: 0; padding: 0; height: " + str(box_size) + "px;}",
    "span {margin: 0; padding: 0;}"
]

with open("index.tmpl", "r") as f:
    template = f.read()

index = template.format(
    styles="\n".join(styles),
    divs="\n".join(divs)
)

with open("index.html", "w") as f:
    f.write(index)

index.tmpl

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>My Website</title>
    <style>
        {styles}
    </style>
  </head>
  <body>
    {divs}
  </body>
</html>

sample.png