Programowanie – Gra w zgadywanie lokalizacji zdjęć w Go

Opublikowane:

30.09.2022

Tym razem utworzymy w Go grę w zgadywanie geolokalizacji. Po części bazuje ona na pomyśle użytym w popularnym Wordle, tyle że zamiast słów odgadujemy miejsce, ocena zależy zaś od odległości od lokalizacji docelowej i kierunku.

Tym razem utworzymy w Go grę w zgadywanie geolokalizacji. Po części bazuje ona na pomyśle użytym w popularnym Wordle, tyle że zamiast słów odgadujemy miejsce, ocena zależy zaś od odległości od lokalizacji docelowej i kierunku.

Autor: Mike Schilli

Polegająca na zgadywaniu słów gra Wordle [1] odniosła ogromny sukces, nie trzeba więc było długo czekać na pojawienie się różnego rodzaju klonów z różnego rodzaju modyfikacjami oryginalnego pomysłu. Jedną z najlepszych jest wciągająca gra geograficzna Worldle [2], w której celem jest odgadnięcie kraju na podstawie jego kształtu. Po każdym nieudanym odgadnięciu Worldle udziela graczowi informacji o tym, jak daleko od celu znajduje się wskazany kraj i w jakim kierunku należy go szukać.

Spójrzmy na Rysunek 1. Gracz nie rozpoznał zarysu państwa i za pierwszym razem wskazał na Księstwo Liechtensteinu. Serwer Worldle natychmiast poinformował go, że docelowa lokalizacja znajduje się 5371 kilometrów na wschód od tego małego europejskiego państwa. Gracz wskazał zatem na Białoruś, ale według serwera Worldle, z Białorusi trzeba by przebyć 4203 kilometry na południowy wschód, aby dotrzeć do celu. Wskazana za trzecim razem Mongolia leży za daleko, bo należałoby udać się 3476 kilometrów na południowy zachód, by wreszcie dotrzeć do celu.

Powoli gracz uświadamia sobie, że docelowy kraj musi znajdować się gdzieś w pobliżu Indii, i wskazuje Pakistan, co okazuje się strzałem w dziesiątkę! Worldle to świetna zabawa, przy czym codziennie można próbować odkryć nowy kraj.

b01-wordle-fr

Rysunek 1: Oryginalna gra Worldle pomaga w nauce geografii.

Prywatne Wordlde

Klonowanie czyjegoś pomysłu 1:1 nie jest szczególnie interesujące. Więcej satysfakcji przynosi zmodyfikowanie go i dostosowanie do własnych potrzeb. Pomyślałem więc, że zamiast krajów ciekawie byłoby użyć zdjęć z mojej ogromnej kolekcji, która przez ostatnie lata szalenie się rozrosła. Analizując dane GPS każdego zdjęcia, silnik gry powinien zadbać o to, by zdjęcia użyte w danej rundzie były zrobione w dużej odległości od siebie. Na początku gry komputer wybiera rozwiązanie losowo z kolekcji zdjęć. Utrzymuje to oczywiście w tajemnicy, a następnie pokazuje graczowi losowo wybrane zdjęcie, wraz ze szczegółami, ile kilometrów znajduje się między miejscem wykonania pokazanego zdjęcia a lokalizacją docelową. Pokazywany jest także kierunek, w którym gracz musi się przemieścić, by dotrzeć do celu.

Uzbrojony w te informacje, gracz musi teraz odgadnąć, które zdjęcie z pozostałych stanowi rozwiązanie. Klika więc na wybrane zdjęcie i otrzymuje informację zwrotną na jego temat – podobnie jak wcześniej, jest to odległość od docelowego miejsca i kierunek, w którym ono się znajduje. Celem gry jest znalezienie rozwiązania w jak najmniejszej liczbie rund – możemy to potraktować jako rodzaj poszukiwania skarbów.

Postanowiłem nazwać swój program Schnitzle. Na telefonie mam wystarczająco dużo zdjęć do wyboru, a generator losowy zapewnia, że gra zawsze wybierze nowe zdjęcia, więc nigdy się nie nudzi.

I... akcja!

Rysunek 2 przedstawia Schnitzle’a w akcji. Jako obraz początkowy komputer wybrał zdjęcie, które przedstawia wędrówkę po Alpach Bawarskich. Według wskazówek cel znajduje się 9457,8 km na północny zachód (NW) od obrazu początkowego. Wydaje się więc bardzo prawdopodobne, że szukane zdjęcie zostało zrobione gdzieś w Ameryce Północnej. W sekcji po prawej stronie gracz klika na zdjęcie Parku Narodowego Pinnacles w Kalifornii (Rysunek 3). Schnitzle ujawnia, że cel znajduje się 168,5 km w kierunku północno-zachodnim od wybranego zdjęcie. Co jest na północ od Pinnacles? Prawdopodobnie rejon zatoki San Francisco, gdzie mieszkam!

b02-schnitzle-1

Rysunek 2: Punkt początkowy: Schnitzle wybiera zdjęcie, które znajduje się 9457,8 km od celu.

b03-schnitzle-2

Rysunek 3: Gracz klika zdjęcie Parku Narodowego Pinnacles, który wciąż znajduje się 168,5 kilometra od celu.

Następnie gracz klika na zdjęcie parkingu przy plaży w Pacifica, gdzie często surfuję (Rysunek 4). Ciepło, coraz cieplej… ale nadal trzeba przebyć 10,2 km od plaży w kierunku północno-wschodnim (NE), by dotrzeć do celu. Jeśli znamy okolicę, prawdopodobnie możemy się domyślić: cel podróży musi znajdować się gdzieś na przedmieściach południowego San Francisco, gdzie znajduje się gigantyczny supermarket Costco. Szukane zdjęcie przedstawia znajdującą się w nim ladę wypełnioną kurczakiem z rożna, o czym świadczy komunikat *** WINNER *** (Rysunek 5). W prawej kolumnie wciąż znajdują się dwa niekliknięte zdjęcia, przedstawiające most w Heidelbergu w Niemczech i jedno przedstawiające piasek na plaży Esplanade w rejonie zatoki San Francisco.

b04-schnitzle-3

Rysunek 4: Parking Pacifica State Beach jest jeszcze 10 kilometrów od celu.

b05-schnitzle-4

Rysunek 5: Rozwiązanie: półka z kurczakiem z rożna w Costco w południowym San Francisco.

Szukajcie, a znajdziecie

Jak więc działa program Go? Przeszukiwanie całej kolekcji zdjęć pobranych z telefonu komórkowego na dysk twardy zajmuje trochę czasu, mimo że znajduje się na szybkim dysku SSD. Dlatego przedstawiony na Listingu 1 program pomocniczy finder.go bada głębię katalogu zdjęć telefonu komórkowego ustawionego w wierszu 18, analizując każdy znaleziony tam obraz JPEG i odczytując dane GPS, jeśli są dostępne, aby móc je później buforować.

Program przesyła wyniki do tabeli w bazie danych SQLite, dzięki czemu gra może w każdej rundzie szybko wybierać nowe obrazy, bez konieczności każdorazowego przeczesywania całego drzewa systemu plików. Wymaganą pustą bazę danych SQLite z potrzebną nam tabelą, która przypisuje dane GPS do nazw plików, można z łatwością utworzyć za pomocą polecenia powłoki, takiego jak to przedstawione na Rysunku 6.

Zanim zatem rozpoczniemy grę, należy uruchomić program z Listingu 1. Kompilujemy go poleceniem:

go build finder.go

Program korzysta z dwóch bibliotek z GitHuba (go-sqlite3 oraz goexif2). Jeden obsługuje opartą na plikach bazę SQLite, a drugi odczytuje nagłówki GPS ze zdjęć JPEG.

Aby kompilator Go zbudował program bez żadnych skarg, najpierw piszemy:

go mod init finder; go mod tidy

aby określić moduł Go do analizowania bibliotek zawartych w kodzie źródłowym, w razie potrzeby pobrać je z GitHuba i zdefiniować ich wersje. Po wykonaniu tej czynności polecenie go build tworzy statyczny plik binarny finder zawierający wszystkie skompilowane biblioteki.

b06-create

Rysunek 6: Pusta baza danych zdjęć, wygenerowana za pomocą klienta sqlite3.

b07-select

Rysunek 7: Po uruchomieniu polecenia finder w bazie danych znajdą się 4162 zdjęcia zawierające współrzędne GPS.

Listing 1: finder.go

01 package main

02 

03 import (

04   "database/sql"

05   "fmt"

06   _ "github.com/mattn/go-sqlite3"

07   exif "github.com/xor-gate/goexif2/exif"

08   "os"

09   "path/filepath"

10   rex "regexp"

11 )

12 

13 type Walker struct {

14   Db *sql.DB

15 }

16 

17 func main() {

18   searchPath := "photos"

19 

20   db, err := sql.Open("sqlite3", "photos.db")

21   w := &Walker{ Db: db }

22   err = filepath.Walk(searchPath, w.Visit)

23   panicOnErr(err)

24 

25   db.Close()

26 }

27 

28 func (w *Walker) Visit(path string,

29   f os.FileInfo, err error) error {

30   jpgMatch := rex.MustCompile("(?i)JPG$")

31   match := jpgMatch.MatchString(path)

32   if !match {

33     return nil

34   }

35 

36   lat, long, err := GeoPos(path)

37   panicOnErr(err)

38 

39   stmt, err := w.Db.Prepare("INSERT INTO files VALUES(?,?,?)")

40   panicOnErr(err)

41   fmt.Printf("File: %s %.2f/%.2f\n", path, lat, long)

42   _, err = stmt.Exec(path, lat, long)

43   panicOnErr(err)

44   return nil

45 }

46 

47 func GeoPos(path string) (float64,

48   float64, error) {

49   f, err := os.Open(path)

50   if err != nil {

51     return 0, 0, err

52   }

53 

54   x, err := exif.Decode(f)

55   if err != nil {

56     return 0, 0, err

57   }

58 

59   lat, long, err := x.LatLong()

60   if err != nil {

61     return 0, 0, err

62   }

63 

64   return lat, long, nil

65 }

Jak pokazano na Rysunku 7, narzędzie finder z Listingu 1 odczytuje około 4000 plików z katalogu ze zdjęciami z telefonu (co trwa około 30 sekund) i dodaje metadane zdjęć do tabeli files w bazie danych SQLite photos.db.

Wywołanie funkcji Walk w wierszu 22. Listingu 1 odbiera wywołanie zwrotne w.Visit zdefiniowane w wierszu 28. Przeglądarka plików wywołuje tę funkcję dla każdego znalezionego pliku, przy czym struktura danych typu Walker towarzyszy jej jako odbiornik, co oznacza, że może natychmiast uzyskać dostęp do uchwytu db otwartej wcześniej bazy danych SQLite.

Dla każdego znalezionego pliku wiersz 31 sprawdza, czy plik ten ma rozszerzenie .jpg (wielkie lub małe litery), a następnie uruchamia funkcję GeoPos() z wiersza 47, aby załadować dane Exif zdjęcia. W idealnej sytuacji w danych tych znajdziemy długość i szerokość geograficzną miejsca, w którym zrobiono zdjęcie, w postaci liczb zmiennoprzecinkowych.

Wiersz 39 wprowadza ścieżkę i dane GPS do tabeli bazy danych za pomocą instrukcji INSERT, używając typowej składni SQL-a. Dzięki temu główny program schnitzle będzie mógł później pobrać lokalizację obrazu i metadane z bazy danych, szukając zdjęć do nowej gry.

Mnóstwo opcji do wyboru

Wybranie losowo kilkunastu zdjęć z kolekcji, która zawiera ich kilka tysięcy, nie jest szczególnie trudne. Trudniejsze natomiast jest zadbanie o to, by lokalizacje zdjęć w jednej rundzie gry nie były zbyt blisko siebie. Wiele zdjęć telefonem komórkowym robi się w domu, a perspektywa poruszania się cal po calu między salonem, balkonem i kuchnią nie jest zbyt ekscytująca.

Zamiast tego chciałem, aby algorytm losowo wybierał obrazy, zapewniając jednocześnie tworzenie nowych interesujących scenariuszy gry w każdej rundzie, zawsze prezentując mieszankę różnych regionów. Na Rysunku 8 widzimy zrzut ekranu aplikacji do zdjęć w telefonie komórkowym, który ilustruje sposób przypisywania ujęć do powiązanych hotspotów na podstawie danych GPS. Algorytm wybiera tylko jeden obraz z danego hotspotu.

W tym przypadku bardzo pomocny jest algorytm k-średnich [3]: k-średnie (inaczej algorytm centroidów) to metoda sztucznej inteligencji [4], stosowana do informacji o skupieniach w uczeniu nienadzorowanym [5] (Rysunek 9). Ze zbioru mniej lub bardziej losowo rozmieszczonych punktów w przestrzeni dwu- lub wielowymiarowej algorytm centroidów wyznacza środki skupień. W grze Schnitzle byłyby to miejsca, w których zrobiono wiele zdjęć telefonem komórkowym, na przykład w domu lub w różnych miejscach podczas wakacji. Następnie algorytm losowo wybiera tylko jeden obraz z każdego z klastrów. Gwarantuje to zachowanie znaczącej odległości między lokalizacjami, w których zostały zrobione poszczególne zdjęcia w każdej rundzie gry.

b08-photo-map

Rysunek 8: Geogrupowanie zdjęć na telefonie komórkowym.

b09-kmeans-github

Rysunek 9: Biblioteka kmeans dla Go na GitHubie.

Rozpoczynająca się w wierszu 18 Listingu 2 funkcja photoSet() ma za zadanie dostarczyć wycinek tablicy sześciu zdjęć (typu Photo), które zostaną wykorzystane w nowej grze. Wiersz 12 definiuje strukturę danych Photo, która zawiera Path jako ścieżkę do pliku obrazu po. Ponadto przechowuje współrzędne geograficzne odczytane z informacji Exif jako Lng (długość geograficzna) i Lat (szerokość geograficzna); obie reprezentują 64-bitowe liczby zmiennoprzecinkowe.

W tym celu photoSet() łączy się z wcześniej utworzoną bazą danych SQLite photos.db (od wiersza 19) i uruchamia zapytanie SELECT (zaczynając od wiersza 23), aby przeanalizować wszystkie wcześniej odczytane pliki ze zdjęciami wraz z ich współrzędnymi GPS. Po zakończeniu działania pętli for, która rozpoczyna się w wierszu 36 i przetwarza wszystkie znalezione krotki tabeli, wszystkie rekordy powinny znaleźć się w tablicy elementów typu clusters.Observations, gotowe do przetworzenia przez pakiet kmeans z GitHuba [6].

Z kolei wywołanie km.Partition() przypisuje współrzędne GPS dziesięciu różnym klastrom. Wiersz 60 odrzuca małe klastry – z mniej niż trzema wpisami. Zapobiega to wielokrotnemu pojawianiu się tych samych zdjęć w każdej grze, nie dając algorytmowi szansy na dostarczenie różnorodności w postaci losowych wyborów z określonej grupy. Z pozostałych klastrów algorytm wybiera maksymalnie sześć (maxClusters) zdjęć, a następnie układa je w losowej kolejności za pomocą funkcji shuffle() z pakietu rand.

Ponieważ biblioteka klastrów kmeans z GitHub nie obsługuje kolekcji zdjęć – potrafi jedynie sortować punkty ze współrzędnymi X/Y, wiersz 41 tworzy tablicę mieszającą lookup, która odwzorowuje długość i szerokość geograficzną zdjęć na obrazy JPEG na dysku. Gdy algorytm powróci później ze współrzędnymi pożądanego obrazu, program może znaleźć powiązany obraz, a następnie załadować go i wyświetlić.

Listing 2: photoset.go

01 package main

02 

03 import (

04   "database/sql"

05   "fmt"

06   _ "github.com/mattn/go-sqlite3"

07   "github.com/muesli/clusters"

08   "github.com/muesli/kmeans"

09   "math/rand"

10 )

11 

12 type Photo struct {

13   Path string

14   Lat  float64

15   Lng  float64

16 }

17 

18 func photoSet() ([]Photo, error) {

19   db, err := sql.Open("sqlite3", "photos.db")

20   panicOnErr(err)

21   photos := []Photo{}

22 

23   query := fmt.Sprintf("SELECT path, lat, long FROM files")

24   stmt, _ := db.Prepare(query)

25 

26   rows, err := stmt.Query()

27   panicOnErr(err)

28 

29   var d clusters.Observations

30   lookup := map[string]Photo{}

31 

32   keyfmt := func(lat, lng float64) string {

33     return fmt.Sprintf("%f-%f", lat, lng)

34   }

35 

36   for rows.Next() {

37     var path string

38     var lat, lng float64

39     err = rows.Scan(&path, &lat, &lng)

40     panicOnErr(err)

41     lookup[keyfmt(lat, lng)] = Photo{Path: path, Lat: lat, Lng: lng}

42     d = append(d, clusters.Coordinates{

43       lat,

44       lng,

45     })

46   }

47 

48   db.Close()

49 

50   maxClusters := 6

51   km := kmeans.New()

52   clusters, err := km.Partition(d, 10)

53   panicOnErr(err)

54 

55   rand.Shuffle(len(clusters), func(i, j int) {

56     clusters[i], clusters[j] = clusters[j], clusters[i]

57   })

58 

59   for _, c := range clusters {

60     if len(c.Observations) < 3 {

61       continue

62     }

63     rndIdx := rand.Intn(len(c.Observations))

64     coords := c.Observations[rndIdx].Coordinates()

65     key := keyfmt(coords[0], coords[1])

66     photo := lookup[key]

67     photos = append(photos, photo)

68     if len(photos) == maxClusters {

69       break

70     }

71   }

72   return photos, nil

73 }

74 

75 func randPickExcept(pick []Photo, notIdx int) int {

76   idx := rand.Intn(len(pick)-1) + 1

77   if idx == notIdx {

78     idx = 0

79   }

80   return idx

81 }

Kontrolowana losowość

Spośród przedstawicieli wszystkich wybranych klastrów gra musi na początku wybrać jedno zdjęcie, które gracz ma odgadnąć. Gra otwiera się z losowym obrazem początkowym, sęk w tym, że nie powinien to być obraz docelowy. Standardowe rozwiązanie rand.Intn(len()) w Go dostarcza losowo i równomiernie rozłożone pozycje indeksu między 0 (włącznie) i len() (wyłączając ten element), czyli wybiera z tablicy czysto losowe elementy.

Rozpoczynająca się w wierszu 75 Listingu 2 funkcja randPickExcept() wybiera losowy element z przekazanej do niej tablicy, nigdy nie ujawniając elementu, który znajduje się w przestrzeni notIdx. Jest to realizowane przez algorytm wybierający tylko elementy w pozycjach indeksu 1.. z elementów w zakresie indeksu 0.., pomijając pierwszy obraz z listy. A jeśli wybór padnie na zabronioną pozycję indeksu notIdx, funkcja zwróci po prostu element 0, który był wcześniej niedostępny. W ten sposób zapewniamy jednakowe prawdopodobieństwo wyboru wszystkich zdjęć jako obrazu początkowego – z wyjątkiem obrazu docelowego, który ma zostać odgadnięty.

Odchudzanie

Listing 3 pomaga załadować pomniejszone zdjęcia telefonu komórkowego do głównego interfejsu programu. I tu napotykamy kolejną przeszkodę: wiele telefonów komórkowych ma zły nawyk przechowywania pikseli obrazu w obróconej orientacji i zaznaczania w nagłówku, że podczas wyświetlania obraz musi być obrócony o 90 lub 180 stopni [7].

To osobliwe zachowanie obsługuje pakiet imageorient, który jest pobierany z GitHuba w wierszu 5 Listingu 3 i który automatycznie obraca każdy obraz, po czym przekazuje go do graficznego interfejsu użytkownika, by można go było wyświetlić. Poza tym przesuwanie ogromnych zdjęć po ekranie nie jest dobrym pomysłem. Dlatego w wierszu 26 pakiet nfnt/resize (również z GitHuba) tworzy z dużych zdjęć poręczne miniatury, używając w tym celu funkcji Thumbnail().

Listing 4 oblicza odległość między lokalizacjami dwóch zdjęć oraz kąt od 0 do 360 stopni, który umożliwiłby udanie się z punktu A do B. Ponieważ Ziemia nie jest płaską powierzchnią, obliczanie tych liczb nie jest tak proste, jak na dwuwymiarowej mapie, ale odpowiednie wzory nie są zbyt skomplikowane i można się z nimi zapoznać online [8]. W wierszu 8 funkcja hike() pobiera z danych GPS dwóch zdjęć długość geograficzną (lng) i szerokość geograficzną (lat), po czym używa funkcji biblioteki golang-geo, w tym GreatCircleDistance() i BearingTo(), aby określić odległość i i odpowiedni kąt między dwoma lokalizacjami.

Tak zwany namiar (bearing) trasy to wartość kąta pomiędzy kierunkiem odniesienia a kierunkiem, w którym obserwowany jest obiekt namierzany; to liczba zmiennoprzecinkowa z zakresu od 0 do 360 stopni. Musimy ją przekonwertować na kierunek kompasu, taki jak północ lub północny wschód, zatem w wierszu 16 dzielimy kąt przez 45, zaokrąglamy wynik do najbliższej liczby całkowitej, a następnie uzyskujemy dostęp do wycinka tablicy w wierszu 15 z tym indeksem. Indeks 0 to N (północ), 1 to NE (północny wschód) i tak dalej. Jeśli indeks spadnie poniżej 0, co może się zdarzyć przy ujemnych kątach, wiersz 19 po prostu dodaje długość wycinka tablicy, aby zamiast tego uzyskać indeks, który odnosi się do wycinka tablicy od końca.

Ozdobny widżet

Aplikacja Schnitzle jest wyposażona w GUI opartym na frameworku Fyne. Tę znakomitą bibliotekę omawialiśmy już [9] w niniejszej kolumnie [10].

Listing 3: image.go

01 package main

02 

03 import (

04   "fyne.io/fyne/v2/canvas"

05   "github.com/disintegration/imageorient"

06   "github.com/nfnt/resize"

07   "image"

08   "os"

09 )

10 

11 const DspWidth = 300

12 const DspHeight = 150

13 

14 func dispDim(w, h int) (dw, dh int) {

15   if w > h {

16     // horyzontalnie

17     return DspWidth, DspHeight

18   }

19   // portrait

20   return DspHeight, DspWidth

21 }

22 

23 func scaleImage(img image.Image) image.Image {

24   dw, dh := dispDim(img.Bounds().Max.X,

25     img.Bounds().Max.Y)

26   return resize.Thumbnail(uint(dw),

27     uint(dh), img, resize.Lanczos3)

28 }

29 

30 func showImage(img *canvas.Image, path string) {

31   nimg := loadImage(path)

32   img.Image = nimg.Image

33 

34   img.FillMode = canvas.ImageFillOriginal

35   img.Refresh()

36 }

37 

38 func loadImage(path string) *canvas.Image {

39   f, err := os.Open(path)

40   panicOnErr(err)

41   defer f.Close()

42   raw, _, err := imageorient.Decode(f)

43   panicOnErr(err)

44 

45   img := canvas.NewImageFromResource(nil)

46   img.Image = scaleImage(raw)

47 

48   return img

49 }

Fyne jest dostarczany z całą gamą etykiet, pól listy, przycisków i innych widżetów, trudno jednak oczekiwać, by obejmował wszelkie możliwe kontrolki. Na przykład w grze Schnitzle gracz klika zdjęcie w prawym okienku, aby gra przeniosła je w lewo. Zazwyczaj jednak klika się widżety przycisków, nie zdjęcia, ponieważ przyciski definiują wywołania zwrotne wykonujące określone działania.

Wystarczy jednak kilka (no, kilkadziesiąt…) wierszy kodu, aby szybko zaprogramować rozszerzenie w Fyne, w którym zaimplementujemy pożądane przez nas, choć niestandardowe zachowanie. Listing 5 definiuje nowy typ widżetu o nazwie clickImage() rozpoczynający się w wierszu 13. Składa się on z obiektu canvas z obrazem miniatury i przyjmuje wywołanie zwrotne, które jest wywoływane, gdy użytkownik kliknie zdjęcie myszą.

Dzięki wbudowanemu w Go mechanizmowi dziedziczenia struktur widget.BaseWidget w wierszu 14 czerpie strukturę z podstawowego widżetu Fyne, zapewniając w ten sposób funkcje wyświetlania, zmniejszania czy ukrywania, które są wspólne dla wszystkich widżetów. Dodatkowo w konstruktorze w dalszej części wiersza 21 widżet dodatku musi wywołać funkcję ExtendBaseWidget(), aby użyć wszystkich funkcji GUI.

Jeszcze jedno: GUI nadal nie wie, jak wyświetlić nowy widżet na ekranie. Dlatego funkcja CreateRenderer() rozpoczynająca się w wierszu 27 zwraca do GUI obiekt typu NewSimpleRenderer, kiedy funkcja jest wywoływana z obrazem jako jedynym parametrem.

Aby widżety zdjęć w Schnitzle reagowały na kliknięcia myszą, ich zdefiniowany w wierszu 19 konstruktor newClickImage() przyjmuje wywołanie zwrotne, które widżet wywołuje później, kiedy zachodzą zdarzenia, takie jak kliknięcia myszą. Wiersz 23 przypisuje tę funkcję do zmiennej instancji cb. Później funkcja Tapped() (zdefiniowana w wierszu 31 i wywoływana przez GUI) po prostu wyzwala zdefiniowane poprzednio w wierszu 32 wywołanie zwrotne za każdym razem, gdy gracz kliknie określony widżet. W ten prosty sposób uzyskaliśmy nowy element GUI: klikalne zdjęcie, które zachowuje się podobnie do widżetu przycisku.

Listing 4: gps.go

01 package main

02 

03 import (

04   geo "github.com/kellydunn/golang-geo"

05   "math"

06 )

07 

08 func hike(lat1, lng1, lat2, lng2 float64) (float64, string, error) {

09   p1 := geo.NewPoint(lat1, lng1)

10   p2 := geo.NewPoint(lat2, lng2)

11 

12   bearing := p1.BearingTo(p2)

13   dist := p1.GreatCircleDistance(p2)

14 

15   names := []string{"N", "NE", "E", "SE", "S", "SW", "W", "NW", "N"}

16   idx := int(math.Round(bearing / 45.0))

17 

18   if idx < 0 {

19     idx = idx + len(names)

20   }

21 

22   return dist, names[idx], nil

23 }

Listing 5: gui.go

01 package main

02 

03 import (

04   "fyne.io/fyne/v2"

05   "fyne.io/fyne/v2/canvas"

06   "fyne.io/fyne/v2/container"

07   "fyne.io/fyne/v2/widget"

08   "math/rand"

09   "os"

10   "time"

11 )

12 

13 type clickImage struct {

14   widget.BaseWidget

15   image *canvas.Image

16   cb    func()

17 }

18 

19 func newClickImage(img *canvas.Image, cb func()) *clickImage {

20   ci := &clickImage{}

21   ci.ExtendBaseWidget(ci)

22   ci.image = img

23   ci.cb = cb

24   return ci

25 }

26 

27 func (t *clickImage) CreateRenderer() fyne.WidgetRenderer {

28   return widget.NewSimpleRenderer(t.image)

29 }

30 

31 func (t *clickImage) Tapped(_ *fyne.PointEvent) {

32   t.cb()

33 }

34 

35 func makeUI(w fyne.Window, p fyne.Preferences) {

36   rand.Seed(time.Now().UnixNano())

37 

38   var leftCard *widget.Card

39   var rightCard *widget.Card

40 

41   quit := widget.NewButton("Quit", func() {

42     os.Exit(0)

43   })

44 

45   var restart *widget.Button

46 

47   reload := func() {

48     leftCard, rightCard = makeGame(p)

49     vbox := container.NewVBox(

50       container.NewGridWithColumns(2, quit, restart),

51       container.NewGridWithColumns(2, leftCard, rightCard),

52     )

53     w.SetContent(vbox)

54     canvas.Refresh(vbox)

55   }

56 

57   restart = widget.NewButton("New Game", func() {

58     reload()

59   })

60 

61   reload()

62 }

Rozpoczynający się w wierszu 41 Listingu 5 przycisk Quit używa swojego wywołania zwrotnego do ogłoszenia końca gry (kiedy użytkownik naciśnie przycisk, wywoływana jest funkcja os.Exit(0)). W analogiczny sposób – dzięki nowemu rozszerzeniu – w wierszu 29 Listingu 6 możemy utworzyć nowe klikalne zdjęcie, które swoim wywołaniu zwrotnym wyśle wybrany indeks do kanału pickCh. Na drugim końcu kanału pobierany jest indeks i uruchamiana animacja, która przesuwa zdjęcie z prawego do lewego okienka.

Pozostała część Listingu 5 poświęcona jest rozmieszczeniu widżetów pokazanych w grze za pomocą makeUI. Centralna funkcja reload() ładuje nową grę podczas uruchamiania programu i po każdym naciśnięciu przycisku Nowa gra.

Niczym karty biblioteczne

Elementy graficzne w oknie gry są ułożone następująco: u góry znajdują się dwa poziome przyciski, poniżej zaś – dwie kolumny obrazów, każda typu Card; wykorzystujemy tu pionowy układ stosów za pomocą NewVBox(). To standardowy widżet Card z kolekcji Fyne, który wyświetla nagłówek (opcjonalnie podnagłówek) oraz ilustrujący go obrazek. Możesz myśleć o tym jako o czymś w rodzaju karty bibliotecznej, tyle że z zawartością multimedialną.

Listing 6 definiuje funkcję main() gry i definiuje poszczególne widżety lewej i prawej kolumny gry w makeGame(). Co więcej, dysponujemy mechanizmem ruchu, który uruchamia się, gdy gracz kliknie zdjęcie w prawej kolumnie.

Listing 6: schnitzle.go

01 package main

02 

03 import (

04   "fmt"

05   "fyne.io/fyne/v2"

06   "fyne.io/fyne/v2/app"

07   "fyne.io/fyne/v2/canvas"

08   "fyne.io/fyne/v2/container"

09   "fyne.io/fyne/v2/widget"

10   "math/rand"

11 )

12 

13 func makeGame(p fyne.Preferences) (*widget.Card, *widget.Card) {

14   pickCh := make(chan int)

15   done := false

16 

17   photos, err := photoSet()

18   panicOnErr(err)

19 

20   photosRight := make([]Photo, len(photos))

21   copy(photosRight, photos)

22 

23   pool := []fyne.CanvasObject{}

24 

25   for i, photo := range photosRight {

26     idx := i

27     img := canvas.NewImageFromResource(nil)

28     img.SetMinSize(fyne.NewSize(DspWidth, DspHeight))

29     clkimg := newClickImage(img, func() {

30       if !done {

31         pickCh <- idx

32       }

33     })

34 

35     pool = append(pool, clkimg)

36     showImage(img, photo.Path)

37   }

38 

39   solutionIdx := rand.Intn(len(photos))

40   solution := photos[solutionIdx]

41 

42   left := container.NewVBox()

43   right := container.NewVBox(pool...)

44 

45   go func() {

46     for {

47       select {

48       case i := <-pickCh:

49         photo := photos[i]

50         dist, bearing, err := hike(photo.Lat, photo.Lng, solution.Lat, solution.Lng)

51         panicOnErr(err)

52 

53         if photo.Path == solution.Path {

54           done = true

55         }

56 

57         subText := ""

58         if done == true {

59           subText = " * WINNER  *"

60         }

61 

62         card := widget.NewCard(fmt.Sprintf("%.1fkm %s", dist, bearing), subText, pool[i])

63         left.Add(card)

64         canvas.Refresh(left)

65 

66         pool[i] = widget.NewLabel("")

67         pool[i].Hide()

68 

69         if done == true {

70           return

71         }

72       }

73     }

74   }()

75 

76   first := randPickExcept(photos, solutionIdx)

77   pickCh <- first

78   return widget.NewCard("Picked", "", left),

79     widget.NewCard("Pick next", "", right)

80 }

81 

82 func panicOnErr(err error) {

83   if err != nil {

84     panic(err)

85   }

86 }

87 

88 func main() {

89   a := app.NewWithID("com.example.schnitzle")

90   w := a.NewWindow("Schnitzle Geo Worlde")

91 

92   pref := a.Preferences()

93   makeUI(w, pref)

94   w.ShowAndRun()

95 }

Elementy w tablicy pool to zdjęcia z prawej kolumny, każde wyświetlane jako CanvasObject. Natomiast lewa kolumna widżetów zawiera zdjęcia już wybrane w trakcie gry. Znajdują się one w początkowej pustej tablicy left. Za każdym razem, gdy zdjęcie w kolumnie prawej widżetu zostanie kliknięte, wywołanie zwrotne powiązane z tym zdjęciem wysyła indeks zaznaczenia do kanału pickCh (wiersz 31).

Następnie działająca równolegle procedura Go zdefiniowana w wierszu 45 obsługuje zdarzenie za pomocą select: oblicza odległość do obrazu docelowego, wywołując hike w wierszu 50, i generuje kartę Fyne z wynikiem w wierszu 62. Funkcja Add() dołącza kartę na dole lewej kolumny (wiersz 63) i używa canvas.Refresh(), aby upewnić się, że zmiany zostały faktycznie wyświetlone przez GUI.

Aby kliknięte zdjęcie zniknęło z prawej kolumny, wiersz 66 wstawia w jego miejsce pusty widżet Label, po czym natychmiast go usuwa, wywołując Hide().

Na początku gry wiersz 76 używa randPickExcept() do wybrania losowego zdjęcia z prawej kolumny, unikając jednak zdjęcia docelowego. Wiersz 77 wstawia pozycję indeksu do kanału pickCh, podobnie jak wywołanie zwrotne widżetu zdjęcia wybranego przez użytkownika, które zrobi później, uruchamiając tę samą animację i przenosząc ją do lewej kolumny.

W Go programiści muszą sprawdzać wyniki po praktycznie każdym wywołaniu funkcji, aby upewnić się, że nie wystąpił błąd, który jest zawsze zwracany jako zmienna err. Jeśli err ma wartość nil, błąd nie wystąpił. Za każdym razem odpowiedni moduł obsługi błędów wymaga trzech wierszy kodu, co zajmuje ogromną ilość miejsca na listingach i powoduje marnowanie papieru oraz cennej przestrzeni w „Linux Magazine”…

Dlatego wiersz 82 po prostu definiuje funkcję panicOnErr(), która za każdym razem wykonuje ten test w jednym wierszu kodu i, jeśli wystąpi błąd, natychmiast przerywa działanie programu, wywołując panic(). W środowiskach produkcyjnych błędy powinny być oczywiście obsługiwane indywidualnie, ale miejsce w drukowanych czasopismach jest ograniczone – poza tym, kto chciałby czytać listingi liczące kilkanaście stron?

Listing 7: Kompilacja Schnitzle

$ go mod init schnitzle

$ go mod tidy

$ go build schnitzle.go gui.go photoset.go image.go gps.go

Witamy

Cały zestaw możemy skompilować za pomocą poleceń przedstawionych na Listingu 7. Wynikowy plik binarny schnitzle można uruchomić w terminalu, a na ekranie pojawi się GUI.

W Linuksie Fyne używa wrappera C, by korzystać w Go z bibliotek libx11-dev, libgl1-mesa-dev, libxcursor-dev oraz xorg-dev. Musimy je więc zainstalować – np. w Ubuntu, użyjemy w tym celu polecenia:

sudo apt-get install libx11-dev libgl1-mesa-dev libxcursor-dev xorg-dev

Dzięki temu go build z Listingu 7 rzeczywiście znajdzie wszystkie wymagane komponenty GUI.

Podsumowanie

Gra w Schnitzle jest wciągająca – warto ją wypróbować na własnej kolekcji zdjęć z wakacji, zwłaszcza jeśli zdjęcia te były zrobione w wielu różnych miejscach. Czasami zaskakująco trudno jest określić w głowie kierunek, który pozwoliłby przejść z jednej części miasta do drugiej. Często byłem zaskoczony wynikami, nawet w przypadku lokalizacji, które wydawały mi się bardzo dobrze znane. Gra oferuje wiele godzin rozrywki dla całej rodziny – i powrót do dawno zapomnianych zdjęć z minionych lat.

Info

[1] Wordle: https://www.nytimes.com/games/wordle/index.html

[2] Worldle: https://worldle.teuteuf.fr/

[3] Algorytm K-średnich: https://pl.wikipedia.org/wiki/Algorytm_centroid%C3%B3w

[4] Mike Schilli, Obliczenie klastrów za pomocą metod SI, „Linux Magazine” 1/2013

[5] Uczenie nienadzorowane: https://pl.wikipedia.org/wiki/Uczenie_nienadzorowane

[6] biblioteka Kmeans na GitHubie: https://github.com/muesli/kmeans

[7] Mike Schilli, Obracanie zdjęć z telefonu w Go, „Linux Magazine” 5/2022

[8] „Oblicz odległość, namiar i więcej między punktami szerokości i długości geograficznej”: http://www.movable-type.co.uk/scripts/latlong.html

[9] Mike Schilli, Programowanie gier za pomocą Go i frameworka Fyne, „Linux Magazine” 3/2022

[10] Mike Schilli Odrzucanie nieudanych zdjęć za pomocą Go and Fyne, „Linux Magazine” 2/2022

Aktualnie przeglądasz

Październik 2022 - Nr 224
LM224_Oct-2022

Top 5 czytanych

Znajdź nas na Facebook'u

Opinie naszych czytelników

Nagrody i wyróżnienia