2025, Oct 02 05:17
Фото на Canvas в Tkinter исчезает? Причина — срок жизни PhotoImage
Разбираем, почему в Tkinter Canvas не показывает PhotoImage: ссылка на объект теряется и его удаляет сборщик мусора. Даем три способа сохранить ссылку.
При модульной организации приложения на Tkinter обманчиво простая переработка кода может сломать отображение изображения на Canvas. Окно появляется, виджеты создаются, но логотип так и не показывается. Источник проблемы вовсе не в Canvas или PIL, а в сроках жизни объектов: объект окна, которому принадлежит изображение, не удерживается, поэтому Python удаляет его сборщиком мусора вместе со ссылкой на PhotoImage.
Как воспроизвести проблему
Ниже — минимальная структура, повторяющая сбойный сценарий: класс в отдельном модуле создает Toplevel и рисует изображение на Canvas, а основной модуль запуска создает окно, не сохраняя ссылку.
from tkinter import *
from PIL import ImageTk, Image
class CreateSecretDialog:
    def __init__(self, parent_win):
        self.parent_win = parent_win
        self.top_win = Toplevel(self.parent_win)
        self.tk_logo = ImageTk.PhotoImage(Image.open("password_img.png"))
        self._setup_shell()
        self._build_canvas()
    def _setup_shell(self):
        self.top_win.title("Add a password")
        self.top_win.config(padx=50, pady=50)
    def _build_canvas(self):
        surface = Canvas(self.top_win, width=205, height=205)
        surface.create_image(102, 102, image=self.tk_logo)
        surface.grid(column=0, row=0)
from tkinter import *
import child_windows
# Запускайте это при нажатии кнопки
def spawn_dialog():
    child_windows.CreateSecretDialog(app_root)
# Корневое окно
app_root = Tk()
app_root.title("Password Manager")
app_root.config(padx=15, pady=15)
btn_new = Button(app_root, command=spawn_dialog, text="Add Password")
btn_new.grid(column=0, row=0)
app_root.mainloop()
Что на самом деле происходит
Экземпляр диалога создается и сразу выходит из области видимости, потому что ссылка на него нигде не хранится. В этот момент сборщик мусора Python волен его удалить. Поскольку ссылка на PhotoImage живет на этом недолговечном объекте, она тоже собирается. Tkinter сохраняет Canvas, но само изображение исчезает, поэтому на экране ничего не видно, хотя холст отображается.
Исправления, которые сохраняют изображение
Вы можете решить проблему, удерживая хотя бы одну долговечную ссылку. Есть три простых способа сделать это, не меняя дизайн интерфейса.
Самый прямой путь — хранить объект диалога на уровне модуля. Так владелец (и его PhotoImage) не будет собран.
# child_windows.py остается таким же, как выше
from tkinter import *
import child_windows
add_secret_view = None  # хранилище на уровне модуля
def spawn_dialog():
    global add_secret_view
    add_secret_view = child_windows.CreateSecretDialog(app_root)
app_root = Tk()
app_root.title("Password Manager")
app_root.config(padx=15, pady=15)
btn_new = Button(app_root, command=spawn_dialog, text="Add Password")
btn_new.grid(column=0, row=0)
app_root.mainloop()
Если не хотите глобальную ссылку на диалог, привяжите изображение к «долго живущему» виджету. Назначьте его атрибутом Canvas (или Toplevel, или даже корневого окна) — так объект останется доступным, пока существует соответствующий виджет.
from tkinter import *
from PIL import ImageTk, Image
class CreateSecretDialog:
    def __init__(self, parent_win):
        self.parent_win = parent_win
        self.top_win = Toplevel(self.parent_win)
        self.tk_logo = ImageTk.PhotoImage(Image.open("password_img.png"))
        self._setup_shell()
        self._build_canvas()
    def _setup_shell(self):
        self.top_win.title("Add a password")
        self.top_win.config(padx=50, pady=50)
    def _build_canvas(self):
        surface = Canvas(self.top_win, width=205, height=205)
        surface.create_image(102, 102, image=self.tk_logo)
        surface.grid(column=0, row=0)
        
        # закрепляем постоянную ссылку на виджете
        surface.tk_logo = self.tk_logo
        # или:
        # self.top_win.tk_logo = self.tk_logo
        # self.parent_win.tk_logo = self.tk_logo
Еще один аккуратный вариант — сделать сам диалог подклассом Toplevel. В таком виде это полноценный виджет, и использование self для всех операций становится естественным. PhotoImage остается привязан к экземпляру виджета, которым управляет Tkinter.
from tkinter import *
from PIL import ImageTk, Image
class CreateSecretDialog(Toplevel):
    def __init__(self, parent_win):
        super().__init__(parent_win)
        self.tk_logo = ImageTk.PhotoImage(Image.open("password_img.png"))
        self._setup_shell()
        self._build_canvas()
    def _setup_shell(self):
        self.title("Add a password")
        self.config(padx=50, pady=50)
    def _build_canvas(self):
        pad = Canvas(self, width=205, height=205)
        pad.create_image(102, 102, image=self.tk_logo)
        pad.grid(column=0, row=0)
Почему это важно в реальных проектах
Как только приложение Tkinter выходит за рамки одного файла, сроки жизни объектов перестают быть очевидными. Создать класс, который держит виджеты, но не сохранять его нигде — скрытая ловушка: вместе с ним «уходят» и объекты изображений. Понимание того, что на PhotoImage должна существовать ссылка все время, пока оно отображается, избавляет от часов отладки невидимых холстов. Если линтер ругается на неинициализированное глобальное хранилище, проинициализируйте его значением None на уровне модуля — это уберет предупреждение и сохранит дизайн понятным.
Выводы
Держите объект в памяти, если он владеет ресурсами, которые еще должны быть на экране. Либо сохраняйте экземпляр диалога в долгоживущей переменной, либо привязывайте PhotoImage к атрибуту виджета вроде Canvas или Toplevel, либо унаследуйте диалог от Toplevel и храните изображение как атрибут экземпляра. Все три подхода гарантируют, что изображение переживет кратковременные локальные области видимости и стабильно отрисуется на вашем Canvas.
Статья основана на вопросе на StackOverflow от Nabodita Gangully и ответе furas.