2025, Nov 06 06:02

Анимация SST в Bokeh: почему не работает и как исправить

Почему анимация SST в Bokeh замирает: вложенные списки не подходят для image. Даём рабочий CustomJS и ColumnDataSource для плавного обновления кадров.

Анимация месячных карт температуры поверхности моря (SST) в Bokeh кажется простой: посчитать кадр для каждого месяца, прикрутить обратный вызов CustomJS и листать изображения по таймеру. Но в браузере получается неподвижный график, хотя данные от месяца к месяцу меняются. Загвоздка тонкая — и кроется на стороне JavaScript.

Постановка задачи

Код ниже создаёт сетку, синтезирует 12 кадров SST и пытается анимировать их в браузере, передавая в JavaScript вложенные списки.

import numpy as np
from bokeh.io import output_file, show
from bokeh.plotting import figure
from bokeh.models import ColumnDataSource, LinearColorMapper, ColorBar, Button, CustomJS, Title
from bokeh.layouts import column
# Сетка
x_deg = np.linspace(-180, 180, 18)
y_deg = np.linspace(-90, 90, 9)
x2d, y2d = np.meshgrid(x_deg, y_deg)
# Генерируем 12 месяцев SST с изменчивостью
tiles = []
for m in range(12):
    field = (
        15
        + 10 * np.cos(y2d * np.pi / 18)
        + 10 * np.sin((x2d + m * 30) * np.pi / 9)
        + 5 * np.cos(y2d * np.pi / 45 + m)
        + np.random.normal(scale=1.5, size=y2d.shape)
    )
    tiles.append(field)
# Масштабирование цветов
cmin = np.min(tiles)
cmax = np.max(tiles)
cmap = LinearColorMapper(palette="Turbo256", low=cmin, high=cmax)
# Начальный кадр
src = ColumnDataSource(data=dict(image=[tiles[0]]))
# График
fig = figure(x_range=(-180, 180), y_range=(-90, 90), width=800, height=400)
fig.image(image="image", x=-180, y=-90, dw=360, dh=180, color_mapper=cmap, source=src)
cb = ColorBar(color_mapper=cmap)
fig.add_layout(cb, 'right')
# Заголовок
hdr = Title(text="Monthly SST - Jan")
fig.add_layout(hdr, 'above')
# Кнопка
play_btn = Button(label="▶ Play", width=100)
# Преобразуем кадры во вложенные списки для JavaScript
nested_tiles = [t.tolist() for t in tiles]
# Обработчик на JavaScript
cb_js = CustomJS(args=dict(source=src, button=play_btn, title=hdr), code=f"""
    if (!window._anim_sst2) {{
        var frames = {nested_tiles};
        var names = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
        var idx = 0;
        window._anim_sst2 = setInterval(function() {{
            idx = (idx + 1) % frames.length;
            source.data = Object.assign({{}}, source.data, {{image: [frames[idx]]}});
            title.text = "Monthly SST - " + names[idx];
        }}, 600);
        button.label = "⏸ Pause";
    }} else {{
        clearInterval(window._anim_sst2);
        window._anim_sst2 = null;
        button.label = "▶ Play";
    }}
""")
play_btn.js_on_click(cb_js)
output_file("sst_animation_working.html")
show(column(play_btn, fig))

В чём настоящая проблема

Критический момент — там, где кадры превращаются во вложенные списки для JavaScript. Bokeh/BokehJS уже много лет не принимает вложенные списки как «псевдо‑2D» массивы. Глиф image ожидает массивы, которые Bokeh сериализует в типизированные массивы с информацией о форме; обычные вложенные списки теряют эту структуру на стороне JS. В итоге браузер получает данные, которые он не может применить к глифу изображения, и во время анимации визуально ничего не меняется.

Исправление: хранить массивы в ColumnDataSource и копировать по месяцам

Загрузите изображения всех месяцев в ColumnDataSource заранее — так Bokeh корректно их сериализует. Затем в колбэке CustomJS копируйте столбец выбранного месяца в столбец image, который управляет глифом.

import numpy as np
from bokeh.io import output_file, show
from bokeh.plotting import figure
from bokeh.models import ColumnDataSource, LinearColorMapper, ColorBar, Button, CustomJS, Title
from bokeh.layouts import column
# Сетка
x_deg = np.linspace(-180, 180, 18)
y_deg = np.linspace(-90, 90, 9)
x2d, y2d = np.meshgrid(x_deg, y_deg)
# Генерируем 12 месяцев SST с изменчивостью
tiles = []
for m in range(12):
    field = (
        15
        + 10 * np.cos(y2d * np.pi / 18)
        + 10 * np.sin((x2d + m * 30) * np.pi / 9)
        + 5 * np.cos(y2d * np.pi / 45 + m)
        + np.random.normal(scale=1.5, size=y2d.shape)
    )
    tiles.append(field)
# Масштабирование цветов
cmin = np.min(tiles)
cmax = np.max(tiles)
cmap = LinearColorMapper(palette="Turbo256", low=cmin, high=cmax)
# Поместим все месяцы в источник данных, чтобы они сериализовались как типизированные массивы
labels = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]
full_ds = {"image": [tiles[0]]}
for i, lab in enumerate(labels):
    full_ds[lab] = [tiles[i]]
src = ColumnDataSource(data=full_ds)
# График
fig = figure(x_range=(-180, 180), y_range=(-90, 90), width=800, height=400)
fig.image(image="image", x=-180, y=-90, dw=360, dh=180, color_mapper=cmap, source=src)
cb = ColorBar(color_mapper=cmap)
fig.add_layout(cb, 'right')
# Заголовок
hdr = Title(text="Monthly SST - Jan")
fig.add_layout(hdr, 'above')
# Кнопка
play_btn = Button(label="▶ Play", width=100)
# JavaScript: копируем столбец месяца в `image`
cb_js = CustomJS(args=dict(source=src, button=play_btn, title=hdr), code="""
    if (!window._anim_sst2) {
        var names = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
        var i = 0;
        window._anim_sst2 = setInterval(function() {
            i = (i + 1) % names.length;
            var data_copy = Object.assign({}, source.data);
            data_copy.image = source.data[names[i]];  // используем правильно сериализованные данные
            source.data = data_copy;                  // при необходимости "подтолкнуть" перерисовку
            title.text = "Monthly SST - " + names[i];
        }, 600);
        button.label = "⏸ Pause";
    } else {
        clearInterval(window._anim_sst2);
        window._anim_sst2 = null;
        button.label = "▶ Play";
    }
""")
play_btn.js_on_click(cb_js)
output_file("sst_animation_fixed.html")
show(column(play_btn, fig))

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

Сбой происходит в BokehJS, то есть в браузере. Отладка через print на стороне Python его не покажет. Если что‑то меняется в Python, но не на холсте, переключайтесь на инструменты разработчика браузера и отлаживайте JavaScript, включая остановку на необработанных исключениях. Хранение данных в ColumnDataSource в виде массивов, которые Bokeh сериализует в типизированные, избавляет от целого класса клиентских ловушек и сводит обновление анимации к простому переназначению столбца.

Выводы

Не передавайте глифам image вложенные списки, ожидая поведения как у 2D‑массивов. Храните массивы кадров в ColumnDataSource, чтобы они сериализовались с информацией о форме, а затем в колбэке CustomJS копируйте выбранный месяц в активный столбец image. Если обновление будто игнорируется, спровоцируйте перерисовку, переустановив source.data. Для диагностики проблем исполнения Bokeh в первую очередь смотрите в браузер. Наличие под рукой минимального воспроизводимого примера помогает быстрее замечать и чинить такие сбои.

Статья основана на вопросе с StackOverflow от Eric Sánchez и ответе bigreddot.