Discarding photo fails with Go and Fyne
Programming Snapshot – Go and Fyne
If you want to keep only the good photos from your digital collection, you have to find and delete the fails. Mike Schilli writes a graphical application with Go and the Fyne framework to help you cull your photo library.
Command-line programs in Go are all well and good, but every now and then you need a native desktop app with a GUI, for example, to display the photos you downloaded from your phone and sort out and ditch the fails. At the end of the day, only a few of the hundreds of photos on your phone will be genuinely worth keeping.
Three years ago, I looked at a graphical tool – very similar to the one discussed in this article – that let the user manually weed out bad photos [1]. It ran on the Electron framework to remote control a Chrome browser via Node.js. Recently, the Go GUI framework Fyne has set its sights on competing with Electron and dominating the world of cross-platform GUI development. In this issue, I'll take a look at how easy it is to write a photo fail killer in Go and Fyne.
Recently, ADMIN, a sister publication of Linux Magazine, featured some simple examples [2] with Fyne, but a real application requires some additional polish. Listing 1 [3] shows my first attempt at a photo app that reads a JPEG image from disk and displays it in a window along with a Quit button. The photo dates back to my latest tour of Germany in 2021, where I set out to track down Germany's best pretzel bakers between Bremen and Bad Tölz. Figure 1 shows the app shortly after being called from the command line with a fabulous pretzel from Lenggries on the southernmost edge of Germany. My only complaint about the app's presentation with the photo and the Quit button is that it takes a good two seconds to load a picture from a cellphone camera with a 4032x3024 resolution from the disk and display it in the application window. Building a tool for image sorting with sluggish handling like this wouldn't attract a huge user base.
Listing 1
img.go
package main import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/widget" "os" ) func main() { win := app.New().NewWindow("imgtest") img := canvas.NewImageFromFile("pretz.jpg") img.SetMinSize(fyne.NewSize(600, 400)) button := widget.NewButton("Quit", func() { os.Exit(1) }) con := container.NewVBox(img, button) win.SetContent(con) win.ShowAndRun() }
Faster Loading
The lightning-fast GUI presented in this issue, which displays the images from the current directory one after the other, moves to the next or previous image through Vi-style control with L or H and dumps the currently displayed image into the trash can directory old/
if you press D (for "delete"). These quick movements make it easy and fast to separate the wheat from the chaff. By the way, if you are bothered by the Vi keyboard binding and prefer to use the cursor keys instead, you can simply make a two-line change to the code and be on your merry way.
While the trivial app from Listing 1 works quite slowly, the iNuke photo app shown in Listing 2 goes through the photo collection much faster. With a few tricks from the performance treasure trove, it displays the next photo almost immediately, with a delay of less than a perceived 10th of a second after you have requested it by pressing L. Magic? Not on the agenda for this column – we're keeping things real.
Listing 2
inuke.go
001 package main 002 003 import ( 004 "container/list" 005 "os" 006 007 "fyne.io/fyne/v2" 008 "fyne.io/fyne/v2/app" 009 "fyne.io/fyne/v2/storage" 010 "fyne.io/fyne/v2/container" 011 "fyne.io/fyne/v2/canvas" 012 "fyne.io/fyne/v2/widget" 013 "github.com/hashicorp/golang-lru" 014 ) 015 016 var Cache *lru.Cache 017 018 func main() { 019 win := app.New().NewWindow("iNuke") 020 021 var err error 022 023 Cache, err = lru.New(128) 024 panicOnErr(err) 025 026 cwd, err := os.Getwd() 027 panicOnErr(err) 028 029 dir, err := storage.ListerForURI( 030 storage.NewFileURI(cwd)) 031 panicOnErr(err) 032 033 files, err := dir.List() 034 panicOnErr(err) 035 036 images := list.New() 037 038 for _, file := range files { 039 if isImage(file) { 040 images.PushBack(file) 041 } 042 } 043 044 if images.Len() == 0 { 045 panic("No images found.") 046 } 047 048 cur := images.Front() 049 050 img := canvas.NewImageFromResource(nil) 051 img.SetMinSize( 052 fyne.NewSize(DspWidth, DspHeight)) 053 lbl := widget.NewLabel( 054 "[H] Left [L] Right [D]elete [Q]uit") 055 con := container.NewVBox(img, lbl) 056 win.SetContent(con) 057 058 showImage(img, cur.Value.(fyne.URI)) 059 preloadImage(scrollRight(images, 060 cur).Value.(fyne.URI)) 061 062 win.Canvas().SetOnTypedKey( 063 func(ev *fyne.KeyEvent) { 064 key := string(ev.Name) 065 switch key { 066 case "L": 067 cur = scrollRight(images, cur) 068 case "H": 069 cur = scrollLeft(images, cur) 070 case "D": 071 if images.Len() == 1 { 072 panic("Not enough images!!") 073 } 074 old := cur 075 cur = scrollRight(images, cur) 076 toTrash(old.Value.(fyne.URI)) 077 images.Remove(old) 078 case "Q": 079 os.Exit(0) 080 } 081 showImage(img, 082 cur.Value.(fyne.URI)) 083 preloadImage(scrollRight(images, 084 cur).Value.(fyne.URI)) 085 }) 086 087 win.ShowAndRun() 088 } 089 090 func scrollRight(l *list.List, 091 e *list.Element) *list.Element { 092 e = e.Next() 093 if e == nil { 094 e = l.Front() 095 } 096 return e 097 } 098 099 func scrollLeft(l *list.List, 100 e *list.Element) *list.Element { 101 e = e.Prev() 102 if e == nil { 103 e = l.Back() 104 } 105 return e 106 }
First off, caching previously loaded photos helps. This means that the GUI renderer only has to get them out of the cache and into video memory in case the user asks for them again. But which photos are worth keeping if they don't all fit in RAM? After all, a directory could hold 5,000 photos of 4MB each – and not everyone has 20GB of memory to spare. The solution is a Least Recently Used (LRU) cache, which holds a predefined maximum number of entries, but if overfilled, simply discards the items whose last access date is the oldest. Newly added entries simply overwrite older ones if the cache is already full.
As a second tuning tool, efficient downsizing of the photos before displaying them helps. Hardly any monitor displays 4032-pixel-width images in full. If you hand over the full-scale photos to the GUI for displaying, you are making it do more work than necessary, and the GUI exacts its revenge in the form of a sluggish response for the user, who – understandably – wants to see a new image without any delay on every keystroke. The nfnt Go library on GitHub offers highly efficient routines for shrinking images; a powerful app always shrinks photos to screen size before they even enter the cache.
And third, a preload mechanism helps the app gain tremendous speed. By design, it always displays photos in a certain order, either forward or backward, depending on the direction the user is navigating. This means that the app can easily predict which photo should appear on the screen with the next keystroke. If the app loads the next likely photo into the cache in the background while the current one is still visible, the Fyne framework can display the next photo almost immediately as soon as the button is pressed.
Zap!
The results are amazing: With these three improvements, the display in the Go program runs at a breathtaking pace and beats many a professional app. In Figure 2, iNuke has just loaded an image showing the author as a tourist in Heidelberg during his 2021 tour of Germany. The small label widget attached below shows which keystrokes are now expected. Pressing H tells the app to jump back to the last picture, L goes forward to the next shot, D deletes the current photo, and Q tells the app to quit. Now, what does the code for this solution look like in Go?
The main program in Listing 2 first defines a new GUI window in lines 18 and 19 with app.New()
and later crams newly loaded images into it with showImage()
in lines 58 and 81.
First, it determines the current working directory in line 25, reads all the JPEG photos it finds therein, and stores the file URLs as their paths in a Fyne framework storage structure. This mechanism is used in Fyne to abstract file paths because not all operating systems provide access to a filesystem. For example, a mobile phone can fetch data from the cloud or from a local database. Thanks to Fyne's abstraction layer, subsequent functions process the data completely transparently.
The keystrokes are intercepted by the SetOnTypedKey()
function, providing a callback routine starting in line 62. Intercepting keystrokes is quite exotic for a GUI that tends to wait for mouse clicks. But Fyne allows it, and a keyboard cowboy like me shuns the mouse like the devil shuns holy water. Pressing L goes to the right, H to the left, and D hoists a delete flag for the current photo and uses toTrash()
to send it to the recycle bin, aka the "old" directory.
The event loop in the main program, which first puts the GUI on the screen and then responds to input lightning-fast with reloaded photos, is started in line 87 by win.ShowAndRun()
.
Array with Holes
So how is the main program supposed to efficiently manage the list of photos that the user browses through like a dervish, deleting an entry here and there that they never want to see again? An array would be the wrong data structure here because arrays with holes mean time-consuming renovation work. Instead, the main program taps into the standard container/list
library. This is a doubly linked list in which the program can quickly move to the next element using Next()
and to the previous element with Prev()
, even if a Remove()
has deleted entries in the meantime (Figure 3). The memory requirement for the entire collection in images
is somewhat higher than for an array because of the links connecting the elements, but speedy deletion of arbitrary elements without any compromises when browsing is well worth the cost.
The scrollRight()
and scrollLeft()
functions in lines 90 and 99, respectively, return the next photo to be displayed when maneuvering to the right (L) or left (H). Even if the user boldly goes beyond the end of the list, no error is thrown. If the user overshoots to the right, scrollRight()
uses Front()
to jump back to the beginning of the list, and if the user moves further to the left from the first element, scrollLeft()
jumps to the last list element.
The routines for scaling and loading image files are shown in Listing 3. In line 15, isImage()
helps to determine whether or not a file is a JPEG photo. It determines the type based on the extension. The task of scaling down large-format cellphone photos to 1200x800 is handled by scaleImage()
starting in line 21, which accesses the resize()
function of the nfnt package from GitHub. The Lanczos3 algorithm implemented there definitely shrinks cellphone photos faster than Fyne after determining that an image is too large to be displayed in an assigned widget.
Listing 3
image.go
01 package main 02 03 import ( 04 "fyne.io/fyne/v2" 05 "fyne.io/fyne/v2/canvas" 06 "fyne.io/fyne/v2/storage" 07 "github.com/nfnt/resize" 08 "image" 09 "strings" 10 ) 11 12 const DspWidth = 1200 13 const DspHeight = 800 14 15 func isImage(file fyne.URI) bool { 16 ext := 17 strings.ToLower(file.Extension()) 18 return ext == ".jpg" || ext == ".jpeg" 19 } 20 21 func scaleImage( 22 img image.Image) image.Image { 23 return resize.Thumbnail(DspWidth, 24 DspHeight, img, resize.Lanczos3) 25 } 26 27 func preloadImage(file fyne.URI) { 28 if Cache.Contains(file) { 29 return 30 } 31 go func() { 32 img := loadImage(file) 33 Cache.Add(file, img) 34 }() 35 } 36 37 func showImage( 38 img *canvas.Image, file fyne.URI) { 39 e, ok := Cache.Get(file) 40 var nimg *canvas.Image 41 if ok { 42 nimg = e.(*canvas.Image) 43 } else { 44 nimg = loadImage(file) 45 Cache.Add(file, nimg) 46 } 47 img.Image = nimg.Image 48 img.Refresh() 49 } 50 51 func loadImage( 52 file fyne.URI) *canvas.Image { 53 img := canvas.NewImageFromResource(nil) 54 55 read, err := 56 storage.OpenFileFromURI(file) 57 panicOnErr(err) 58 59 defer read.Close() 60 raw, _, err := image.Decode(read) 61 panicOnErr(err) 62 63 img.Image = scaleImage(raw) 64 img.FillMode = canvas.ImageFillContain 65 66 img.SetMinSize( 67 fyne.NewSize(DspWidth, DspHeight)) 68 69 return img 70 }
Buy this article as PDF
(incl. VAT)
Buy Linux Magazine
Subscribe to our Linux Newsletters
Find Linux and Open Source Jobs
Subscribe to our ADMIN Newsletters
Support Our Work
Linux Magazine content is made possible with support from readers like you. Please consider contributing when you’ve found an article to be beneficial.
News
-
Latest Cinnamon Desktop Releases with a Bold New Look
Just in time for the holidays, the developer of the Cinnamon desktop has shipped a new release to help spice up your eggnog with new features and a new look.
-
Armbian 24.11 Released with Expanded Hardware Support
If you've been waiting for Armbian to support OrangePi 5 Max and Radxa ROCK 5B+, the wait is over.
-
SUSE Renames Several Products for Better Name Recognition
SUSE has been a very powerful player in the European market, but it knows it must branch out to gain serious traction. Will a name change do the trick?
-
ESET Discovers New Linux Malware
WolfsBane is an all-in-one malware that has hit the Linux operating system and includes a dropper, a launcher, and a backdoor.
-
New Linux Kernel Patch Allows Forcing a CPU Mitigation
Even when CPU mitigations can consume precious CPU cycles, it might not be a bad idea to allow users to enable them, even if your machine isn't vulnerable.
-
Red Hat Enterprise Linux 9.5 Released
Notify your friends, loved ones, and colleagues that the latest version of RHEL is available with plenty of enhancements.
-
Linux Sees Massive Performance Increase from a Single Line of Code
With one line of code, Intel was able to increase the performance of the Linux kernel by 4,000 percent.
-
Fedora KDE Approved as an Official Spin
If you prefer the Plasma desktop environment and the Fedora distribution, you're in luck because there's now an official spin that is listed on the same level as the Fedora Workstation edition.
-
New Steam Client Ups the Ante for Linux
The latest release from Steam has some pretty cool tricks up its sleeve.
-
Gnome OS Transitioning Toward a General-Purpose Distro
If you're looking for the perfectly vanilla take on the Gnome desktop, Gnome OS might be for you.