1

Edit: This was resolved in the comments. As the error message I got when running this code was the same as the one I had encountered previously with a garbage-collection-related problem, I incorrectly assumed that the error in this code had the same cause. The real problem was using a value of the wrong type (None instead of '') to 'clear' the image parameter of the Label widgets.


SUMMARY:

An image used in a tkinter Label widget is being garbage collected (I believe) despite having a backup reference, but only in a very specific set of circumstances.


WHAT I TRIED:

The issue of tkinter garbage collecting images is well known and many solutions have been already posted here on SO, as well as on other programming forums, such as one where I first found a solution over a year ago by Googling the error message 'pyimage doesn't exist'. The solution as I recall was very similar to the one discussed in the main SO question linked below.

The canonical SO question seems to be this one: Why does Tkinter image not show up if created in a function? which has been answered by Bryan Oakley (https://stackoverflow.com/a/16424553/18248018) in a way that solves the issue for me in 99% of all cases: store an extra reference to the image somewhere in the program.

However, although the method of storing an extra reference to the image in global scope has always worked for me before, in this one very specific situation it is not working. I tried a different method of storing a reference to the image (as a property of the widget itself, per Kevin's answer here: https://stackoverflow.com/a/27430839/18248018), and also of changing the argument to ImageTk.PhotoImage to use the file kwarg: (acw1668's answer https://stackoverflow.com/a/60739446/18248018), but neither of these fixed this particular issue.


THE PROBLEM:

In a program that involves moving/swapping objects by clicking on different Label widgets, I want to highlight the currently selected object by changing the corresponding widget's background color bg to yellow as long as the object remains selected, then reset the background to the original color once the object is deselected. Clicking the widget which corresponds to an object 'selects' it, and either clicking the same widget again or else performing a successful swap/move (by clicking a different widget) deselects the object. When an object is moved or swapped, the image associated with it moves, as can be seen in the code example.

Everything is working (including object swaps/moves and swapping/moving the corresponding images) except for 'pyimage doesn't exist' errors being generated when attempting to reset the background colors.

Oddly enough, the error in this program does not occur when dealing directly with images. Instead, what triggers it is trying to update the background color of a Label widget which also has an image.

The program in which I encountered this very frustrating error is 1000s of lines long, so I extracted the particular functionality surrounding the issue, generalized the variable names for clarity, and added new comments explaining when and where the problem is occurring.

In order to be able to reproduce the issue, I've included the two images I used in this test version at the bottom of the post. The main .py script file is inside a folder, at the same level as another folder titled 'Images'. The two .png files go inside the 'Images' folder inside the main project folder. The names of the outer folder and the .py file shouldn't matter.

MCVE (sorry for the length, but it's a lot shorter than the original!)

import tkinter as tk
from PIL import ImageTk, Image

window = tk.Tk()
window['width'] = 400
window['height'] = 400
window.title("Object Swapper")

# Global variables corresponding to ones used in the full program
labelImgWidth = 90
labelImgHeight = 90
# More global variables
globalVars = {'move_obj_info': None}

# Stores references to the images used in the tk.Label widgets, to avoid premature garbage collection.
storage = {'images': []}

# Can't just store instances of ImageTk.PhotoImage, because size will not be constant and must
# be specified later
get_imgs_for_size = (
    lambda x, y: ImageTk.PhotoImage(Image.open("Images/test_img1.png").resize((int(x), int(y)))),
    lambda x, y: ImageTk.PhotoImage(Image.open("Images/test_img2.png").resize((int(x), int(y))))

    # THIS SOLUTION (https://stackoverflow.com/a/60739446/18248018) DID *NOT* FIX THE PROBLEM IN THIS CASE
    #lambda x, y: ImageTk.PhotoImage(file="Images/test_img1.png"),
    #lambda x, y: ImageTk.PhotoImage(file="Images/test_img2.png")
)

# First item in the tuple is the label widget.  Next 4 items are the x, y, width and height.
labels = (
    ((tk.Label(master=window, bg="darkblue"), 100, 100, 100, 100), (tk.Label(master=window, bg="darkblue"), 100, 200, 100, 100)),
    ((tk.Label(master=window, bg="darkblue"), 200, 100, 100, 100), (tk.Label(master=window, bg="darkblue"), 200, 200, 100, 100))
)
for row in range(len(labels)):
    for col in range(len(labels[row])):
        item = labels[row][col]
        item[0].place(x=item[1], y=item[2], width=item[3], height=item[4])
        # Attach the click event listener to each label, with parameters determined by the label's position
        # in the 2-dimensional tuple
        item[0].bind('<Button-1>', lambda event, row=row, col=col: select_obj(row, col))

# The labels' images are set inside a function, which introduces scoping issues.
# It is not possible to avoid using a function, since the images will be set, cleared and
# reset multiple times in the full program.
def set_label_imgs():
    # Reset the list first, to keep it from getting bloated with duplicates every time this function runs
    storage['images'] = []
    
    for i in range(len(labels)):
        for j in range(len(labels[i])):
            pic = None if objs[i][j] is None else objs[i][j]['image'](labelImgWidth, labelImgHeight)
            # Save a reference to the current image in the 'storage' dict, then use the same image in the label 
            storage['images'].append(pic)
            labels[i][j][0]['image'] = pic

            # THIS METHOD OF ATTACHING ANOTHER REFERENCE DIRECTLY TO THE LABEL WIDGET *ALSO*
            # DID NOT WORK IN THIS CASE (https://stackoverflow.com/a/27430839/18248018)
            #labels[i][j][0].image = pic
            
            # Attemp to reset background colors of the labels to unselected color: WILL FAIL
            try:
                labels[i][j][0]['bg'] = 'darkblue'
                labels[i][j][0]['bg'] = 'darkblue'
            except tk.TclError as error:
                print("Exception occurred inside set_label_imgs while attempting to reset label background color: " + str(error))

# Called when any of the labels is clicked
def select_obj(inRow, inColumn):
    # Don't allow selecting an empty slot.
    if (objs[inRow][inColumn] == None) and (globalVars['move_obj_info'] is None):
        return

    else:
        data = None if objs[inRow][inColumn] is None else objs[inRow][inColumn]['data']
        
        # If nothing is already selected, add the clicked label's corresponding object's data to the global variables dict
        if globalVars['move_obj_info'] is None:
            globalVars['move_obj_info'] = {'row': inRow, 'col': inColumn, 'data': data}
            labels[inRow][inColumn][0]['bg'] = 'yellow'
            
        else:
            # If second label is the same one that is already selected, toggle it back to deselected.
            if (globalVars['move_obj_info']['row'] == inRow) and (globalVars['move_obj_info']['col'] == inColumn):
                labels[inRow][inColumn][0]['bg'] = 'darkblue'
                globalVars['move_obj_info'] = None

            # Swap first label's object with a second label's object, or move first label's object into empty second label
            else:
                move_obj(globalVars['move_obj_info']['row'], globalVars['move_obj_info']['col'], globalVars['move_obj_info']['data'],
                         inRow, inColumn, data)
               
# Moves object from one label to another.  If the destination already has an associated object, interchange the two.
def move_obj(row1, column1, data1, row2, column2, data2):
    globalVars['move_obj_info'] = None
    
    # Move object to empty label
    if data2 is None:
        objs[row2][column2] = objs[row1][column1]
        objs[row1][column1] = None

    # Swap two objects
    else:
        temp = objs[row2][column2]
        objs[row2][column2] = objs[row1][column1]
        objs[row1][column1] = temp

    # Re-determine which images to display for each label after object(s) is/are moved
    set_label_imgs()
    # Attemp to reset background colors of the labels to unselected color: WILL FAIL
    try:
        labels[row1][column1][0]['bg'] = 'darkblue'
        labels[row2][column2][0]['bg'] = 'darkblue'
    except tk.TclError as error:
        print("Exception occurred inside move_obj while attempting to reset label background color: " + str(error))


# Represents objects from the full program which have associated image properties as well
# as other data
objs = [
    [{'image': get_imgs_for_size[0], 'data': 123}, None],
    [None, {'image': get_imgs_for_size[1], 'data': 456}]
]

set_label_imgs()
window.mainloop()

THE IMAGES:

This one is named 'test_img1.png':

First Image

And this one is named 'test_img2.png':

Second Image

Quack E. Duck
  • 594
  • 1
  • 4
  • 20
  • 1
    What are we supposed to do to reproduce the problem? I can click on the images and the border color changes, but I don't see any errors. – Bryan Oakley May 19 '22 at 23:53
  • 2
    It is because you use `None` for the `image` option to clear the image of a label but setting option `labels[i][j][0]['image'] = pic` (`pic` is None) actually does nothing (`image` option will not be updated). Try changing the line `pic = None if ...` to `pic = '' if ...`. – acw1668 May 20 '22 at 00:54
  • 1
    code works for me without error when I use `pic = '' if ...` as suggested @acw1668 – furas May 20 '22 at 01:05
  • @BryanOakley to answer your first comment, when I ran the original version of the code (before replacing `None` with the empty string) I got this error message printed in IDLE: `Exception occurred inside move_obj while attempting to reset label background color: image "pyimage11" doesn't exist`. The label background colors also stayed yellow after moving/swapping, instead of changing back to blue like I intended – Quack E. Duck May 20 '22 at 03:49
  • If this did *not* happen for you, I'd be interested to hear what platform and IDE you're using. I tested it only with IDLE on macOS Monterey. – Quack E. Duck May 20 '22 at 03:53
  • 1
    I'm on OSX, and using emacs rather than an IDE. – Bryan Oakley May 20 '22 at 06:53
  • 1
    @BryanOakley Did you click one of the empty cell after clicking an image? The *exception message* will not be shown if just clicking an image. – acw1668 May 20 '22 at 06:58
  • 1
    Ah, no. It never occurred to me to click in an empty cell. – Bryan Oakley May 20 '22 at 08:25

0 Answers0