2025, Nov 04 09:02

Интерактивный matplotlib (ipympl) в JupyterLab: почему список пуст и как это исправить

Разбираем асинхронность событий в JupyterLab с ipympl: почему глобальный список пуст после кликов, как корректно работать с колбэками, виджетами и mpl_connect.

Когда вы подключаете интерактивность matplotlib в JupyterLab (через ipympl), легко начать относиться к ноутбуку как к линейному скрипту: выполнил ячейку, дважды кликнул и ожидаешь, что последний print отразит обновлённое состояние. Но событийный код так не работает. Колбэк выполняется позже, уже после завершения ячейки, поэтому всё, что печатается в конце ячейки, не учитывает изменения, произошедшие во время кликов. Именно поэтому глобальный список координат кажется пустым, хотя каждый клик на экране явно выводит x и y.

Минимальный пример, который кажется рабочим, но печатает пустой список

Ниже — типичный шаблон, где координаты добавляются в обработчике события, но финальный print запускается слишком рано в жизненном цикле ячейки.

%matplotlib ipympl

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import curve_fit
from scipy import stats
import os
import ipywidgets as widgets

log_area = widgets.Output()
picks = []

# пример данных для линейного графика
x_series = [1, 2, 3, 4, 5]
y_series = [1, 3, 2, 5, 1]

chart, axes = plt.subplots(figsize=(4, 4))
axes.plot(x_series, y_series, 'k-', linewidth=1.5)
axes.set_xlabel("Time (sec)")
axes.set_ylabel(r"Temperature ($\degree$ C)")
axes.tick_params(axis="both", direction='in', top=True, right=True)

plt.show()

@log_area.capture()
def on_press(evt):
    ix, iy = evt.xdata, evt.ydata
    print('x = ', ix, 'y = ', iy)

    global picks
    picks.append(ix)
    picks.append(iy)
    
    # Отключиться после 2 кликов
    if len(picks) == 4:
        chart.canvas.mpl_disconnect(hook)
    
    return picks

hook = chart.canvas.mpl_connect('button_press_event', on_press)
display(log_area)
print(picks)

Обработчик добавляет значения в глобальный список и отключается после двух кликов. Но последняя строка печатает пустой список, потому что выполняется до того, как произойдёт хотя бы один клик. Ноутбук не перезапустит эту строку автоматически после завершения кликов.

Что на самом деле происходит

В Jupyter (и в GUI вообще) код внизу ячейки выполняется немедленно, а взаимодействия пользователя происходят позже через колбэки. Ядро не ждёт щелчков мышью так, как консоль ждёт input(). Это значит, что печать глобального списка сразу после регистрации события покажет его начальное состояние, а не состояние после кликов. Сам список действительно обновляется глобально — это видно по выводу внутри обработчика, — но ранний print снаружи не «живой».

Есть и другой нюанс: возвращаемое значение из обработчика события здесь не приносит пользы. Механизм GUI вызывает вашу функцию, но никуда полезно не направляет её результат. Если значения нужны сразу, используйте их прямо в обработчике. Если они понадобятся позже, запустите соответствующий код вручную, когда клики завершены — например, выполните другую ячейку после двух кликов.

Рабочий подход: показывайте состояние во время кликов и используйте его позже

Практичный приём — обновлять элемент интерфейса во время кликов и отключать событие, когда обе точки собраны. Нужна мгновенная обработка координат? Делайте её прямо в обработчике. Предпочитаете обработать позже — просто выполните последующую ячейку: к тому моменту глобальное состояние уже заполнено. Можно также добавить кнопку, которая запустит следующий шаг, когда будете готовы.

%matplotlib ipympl

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import curve_fit
from scipy import stats
import os
import ipywidgets as widgets

log_area = widgets.Output()
picks = []

x_series = [1, 2, 3, 4, 5]
y_series = [1, 3, 2, 5, 1]

chart, axes = plt.subplots(figsize=(4, 4))
axes.plot(x_series, y_series, 'k-', linewidth=1.5)
axes.set_xlabel("Time (sec)")
axes.set_ylabel(r"Temperature ($\degree$ C)")
axes.tick_params(axis="both", direction='in', top=True, right=True)

plt.show()

@log_area.capture()
def on_press(evt):
    global picks
    
    ix, iy = evt.xdata, evt.ydata
    print('x = ', ix, 'y = ', iy)

    picks.append(ix)
    picks.append(iy)

    status_label.value = f'coord: {picks}'
    
    if len(picks) == 4:
        chart.canvas.mpl_disconnect(hook)
        # здесь при необходимости можно сразу использовать picks

hook = chart.canvas.mpl_connect('button_press_event', on_press)

status_label = widgets.Label(value=f'coord: {picks}')

display(status_label, log_area)

Теперь состояние видно по мере кликов, а событие корректно отключается после двух нажатий. Если вам нужно использовать picks в другом месте, выполните следующую ячейку после выбора точек — список глобальный и к этому моменту уже заполнен.

Почему это важно

Ячейки ноутбука и GUI-колбэки живут на разных временных шкалах. Если обращаться с колбэками как с синхронными шагами линейного скрипта, появятся странности: пустые выводы, игнорируемые возвращаемые значения и код, который «иногда работает». Осознание того, что Jupyter не ждёт ваших кликов, помогает правильно строить интерактивную логику: обновлять интерфейс в обработчиках, выполнять расчёты сразу внутри колбэка при необходимости или запускать продолжение после взаимодействий.

Итог

Фиксируйте координаты кликов внутри обработчика, при необходимости показывайте состояние через виджет и отключайте подписку, когда точек достаточно. Не полагайтесь на завершающий print в той же ячейке — он выполняется слишком рано. Если координаты понадобятся позже, просто выполните следующую ячейку после завершения кликов. И помните: для GUI-событий возвращаемое значение обработчика не поможет — действуйте с данными прямо в колбэке или явно запускайте следующий шаг.

Статья основана на вопросе с StackOverflow от Karla Wagner и ответе от furas.