Neurokuvatreenit, harjoitus 1

Näissä treeneissä tutustutaan neuroverkkopohjaiseen kuvankäsittelyyn, tehdään tai muutetaan pikselikuvia uudella tavalla, ikäänkuin ohjaamalla kuvaa itseään, sen jokaista pikseliä, kohti haluttua lopputulosta, sen sijaan että kirjoitettaisiin koodia joka lukee ja muuttaa itse pikseleitä.

Tässä ensimmäisessä osassa ei vielä käytetä edes neuroverkkoakaan. Kokeillaan vain, mitä kuvan ohjaaminen kohti tavoitetta käytännössä tarkoittaa.

Periaate on oikeastaan aika yksinkertainen, kun sen ensin vain tajuaa. Tässä yksinkertaisessa esimerkissä luomme satunnaisesti alustetun pikselikuvan, ns. lumisadetta, ja ohjaamme sen kohti tavoitetta, puhtaan vihreää kuvaa.

Ohjaus tapahtuu mittaamalla, kuinka paljon olemme pielessä tavoitteesta ja muuttamalla kuvaa aavistuksen verran kohti tavoitetta. Poikkeaman voimme arvioida vaikkapa laskemalla kunkin pikselin virheen ja ottamalla näistä keskiarvon. Jos pystymme ohjaamaan pikseleitä siihen suuntaan, että virhe pienenee, voimme kenties päästä tavoitteeseen, jolloin virhe on nolla.

Mutta vaikka tiedämme kokonaisvirheen, kuinka pystymme siitä päättelemään kuinka paljon kutakin yksittäistä pikseliä pitää muuttaa, ja mihin suuntaan? Siihen löytyy keino, ns. backpropagation, eräänlainen virheen valuttaminen laskentaketjun läpi, takaperin, niin että lopuksi tiedämme kunkin pikselin kohdalla kuinka paljon sitä pitää muuttaa.

Tällä kertaa tavoitteemme on niin yksinkertainen, että kun tiedämme kaukanako kukin pikseli on puhtaasta vihreästä, voisimme suoraan korjata pikselin tavoitteen mukaiseksi. Mutta jatkossa, kun tavoitteet mutkistuvat, niitä voi olla useitakin, ei suoraa tietä optimiin enää ole. Siksi optimia haetaan pienin askelin, tätä hoitaa ns. optimoija (optimizer), jolle kerrotaan miten hienojakoisin askelin pitää edetä (learning rate, koulutus- tai oppimistahti). Yleensä ongelmat ovat sellaisia, että liian suurin askelin eksytään reitiltä, liian pienin askelin optimin löytäminen taas vie kauemmin.

Koodin tasolla homma menee seuraavasti. Käytämme python-kieltä ja pytorch-kirjastoa, joiden lisäksi otamme käyttöön muita kirjastoja tarpeen mukaan. Ohjelman alussa ilmoitamme mitä kirjastoja tulemme tässä käyttämään.

import torch
from torchvision.transforms import Normalize
from torchvision.utils import save_image

Luodaan sitten nuo kaksi kuvaa, satunnainen ja tavoite. Kuvan esittämiseen käytämme 3-ulotteista taulukkoa, jossa kutakin pääväriä (R, G, B eli punainen, vihreä ja sininen) on kaksiulotteinen pikselikuva (korkeus x leveys). Pikselien esittämiseen on parikin erilaista käytäntöä: liukulukuina nollasta ykköseen tai kokonaislukuina nollasta 255:een, pienempi aina tummempi ja suurempi kirkkaampi. Tässä mennään aluksi liukuluvuilla nollasta ykköseen.

# make a 256x256 rgb image with random pixels
# pixel values here float in range is 0..1 

imgG = torch.Tensor(3,256,256).normal_(mean=0.5, std=0.5).clamp_(0,1) 

# check shape, min and max

print(imgG.shape, imgG.min(), imgG.max())

# make target image with green pixels

imgT =  torch.zeros(3,256,256)
imgT[1,:,:] = 1

Tuossa viimeisellä rivillä siis luodaan se puhtaan vihreä kuva. Kuinka? Jos jätämme pois koko rivin, meillä on taulukko jonka kaikkien pikselien kaikki värit ovat 0, mikä on täysin musta kuva. Nyt asetamme keskimmäisen värikanavan ykköseksi, saamme vihreän kuvan. Värikanavien indeksit menevät tuossa 0, 1, 2 jolloin 1 on se keskimmäinen. Kaksoispisteet taas kertovat, että täytämme pikselit laidasta laitaan; jatkossa opimme myös kajoamaan vain osaan kuvasta.

Meillä siis pikselien arvot nyt menevät nollan ja ykkösen välillä. Neuroverkkojen kanssa yleensä toimii paremmin, jos arvot jakautuvat nollan molemmin puolin. Luomme sitä varten funktion norm() ja normalisoimme arvot välille -1 ja 1. Tämä ei aina ole välttämätöntä, mutta hyvä oppia.

# normalize both images into range -1 .. 1

norm = Normalize((0.5,0.5,0.5),(0.5,0.5,0.5))
imgG = norm(imgG)
imgT = norm(imgT)

print(imgG.shape, imgG.min(), imgG.max())

Sitten tarvitsemme optimoijan hoitamaan optimin hakua. Se menee näin. Määrittelemme oppimistahdin lr. Mietimme hetken mitä haluamme muuttaa… sehän on kuva imgG, kerromme että se tarvitsee gradientin. Sillä tarkoitamme haluamme tietää miten paljon nimenomaan se, imgG, on pielessä ja mihin suuntaan. Luomme vielä itse optimoijan ja kerromme sille että nyt optimoidaan juuri tuota imgG:tä tahdilla lr.

# We are going to optimize imgG, so we need gradients for it

lr = 0.05 # you might want to change this and see what happens

imgG.requires_grad = True

optimizer = torch.optim.Adam([imgG], lr)

Nyt voidaankin käydä optimoimaan. Se tehdään silmukassa jota käydään läpi enintään 150 kertaa. Silmukassa ensin nollataan optimoija, ettei sillä ole mitään vanhaa tietoa muistissa. Sitten lasketaan virhe. Voimme suoraan laskea erotuksen noista kahdesta kuvasta, tuloksena on 3-ulotteinen taulukko jossa on ero laskettuna jokaisen värikanavan jokaisen pikselin kohdalla. Otetaan näistä itseisarvo (abs) eli negatiiviset arvot käännetään positiivisiksi. Ja sitten keskiarvo näistä, saadan yksi luku joka mittaa kuvien välistä eroa.

Nyt pitää jyvittää virhe kullekin pikselille. Se käy helpommin kuin uskoisikaan. Loss.backward() laskee virheestä lähtien takaperin koko laskentaketjun, ikäänkuin valuttaa virheen takaisin kunkin pikselin kohdalle, jossa se tallentuu imgG:n gradienttiin. Tässä kohtaa voisimme vaikka tutkia mistä se koostuu, lisäämällä rivin print(imgG.grad). Se on samanmuotoinen kuin kuvakin, 3 x 256 x 256 taulukko, jossa on poikkeama kullekin pikselille.

Voisimme itsekin alkaa muuttaa imgG:tä sen gradientin mukaan, mutta yleensä parempi jättää se optimoijan tehtäväksi, kuten tässä.

Lopuksi tulostamme kierroksen numeron ja senhetkisen virheen arvon, jotta voimme seurata pieneneekö virhe eli toimiiko optimointi. Talletamme myös tämänhetkisen imgG:n levylle jotta voimme seurata kuvan muuttumista.

Ja sitten sama uudestaan, 150 kertaa.

niter = 150

for i in range(0, niter):
    optimizer.zero_grad()

    # calculate loss
    loss = torch.abs(imgT - imgG).mean()

    # run backwards to find gradient (how to change imgG to make loss smaller) 
    loss.backward()

    # run optimizer to actually change imgG
    optimizer.step()

    # print loss to show how we are doing
    print(i, loss.item())

    # save image
    save_image(imgG, "imgG"+str(i)+".jpg")
    

Tuosta videolta alta voi katsoa kun ajan kokeeksi tuon harjoituksen koodin.

Seuraavassa harjoituksessa jatketaan tästä, mutta käytetään kohteena omaa kuvaa. Kokeillaan myös mitä tapahtuu jos meillä on kaksi eri tavoitetta.

Lopuksi mietittävää tai jopa itse kokeiltavaa.

1. Mitä tapahtuu jos oppimistahtia muutetaan. Paljon pienemmäksi? Paljon isommaksi? Mitä jos se on 1, tai vieläkin suurempi?

2. Entä jos vihreän sijaan haluaakin punaisen tai sinisen neliön? Tai kokonaan jonkin muun värin?

3. Aika yleisesti on tiedossa että neuroverkkoja koulutetaan. Koulutettiinko tässä neuroverkko? Oliko meillä tässä neuroverkko? Mitä itse asiassa koulutimme?

Comments are closed