概述

Bar Chart Race(条形图赛跑)是一种流行的动态数据可视化形式,用水平条形图的排名变化来展示数据随时间的演变。本文以"2020-2025 全球游戏销量排行"为例,记录使用 Plotly 实现条形图赛跑动画过程中遇到的典型问题及优化方案,涵盖动画参数调试、页面持久化、UI 控件优化和帧插值丝滑化等关键环节。
在这里插入图片描述

基础实现

使用 plotly.graph_objects 构建水平条形图,通过 go.Frame 定义动画帧,每年一帧:

import plotly.graph_objects as go

fig = go.Figure()

# 添加初始帧的 Bar traces
fig.add_trace(go.Bar(
    x=[sales], y=[game],
    orientation='h',
    marker_color=color_map[game]
))

# 每年一个 Frame
frames = []
for year in years:
    frame = go.Frame(
        data=[go.Bar(...) for _, row in year_data.iterrows()],
        name=str(year)
    )
    frames.append(frame)
fig.frames = frames

通过 updatemenus 添加播放/暂停按钮,sliders 添加年份滑块,即可实现基础动画。

问题一:刷新页面后无法访问

现象

fig.show() 打开浏览器后,刷新页面提示"无法访问此网站"。

原因

fig.show() 启动一个临时本地服务器来渲染图表,Python 脚本运行结束后服务即关闭,刷新自然失效。

解决方案

改用 fig.write_html() 输出为静态 HTML 文件,浏览器直接打开本地文件,随时刷新都可用:

fig.write_html("game_sales_race.html", auto_open=True)

问题二:添加动画参数调试控件

需求

希望在图表界面上直接调整 frame.duration(帧时长)、transition.duration(过渡时长)和 easing(缓动函数),方便动态调试动画效果。

实现

利用 Plotly 的 updatemenus 添加多个 dropdown 类型控件,每个选项通过 method: 'animate' 触发不同参数的动画:

updatemenus=[
    # 暂停按钮
    {'type': 'buttons', 'buttons': [{'label': '暂停', 'method': 'animate', ...}]},
    # 帧时长下拉
    {
        'type': 'dropdown',
        'buttons': [
            {
                'label': f'帧时长: {d} ms',
                'method': 'animate',
                'args': [None, {
                    'frame': {'duration': d, 'redraw': True},
                    'fromcurrent': True,
                    'transition': {'duration': int(d * 0.9), 'easing': 'linear'}
                }]
            } for d in [50, 100, 200, 300, 500, 800]
        ],
        'active': 0,  # 默认选中项索引
    },
    # 过渡时长下拉(结构类似)
    # 缓动函数下拉(结构类似)
]

下拉框文字截断问题

Plotly 下拉框宽度由内容决定。中文标签容易被截断,解决方法:

  • 标签左右加空格留白:f' 帧时长: {d} ms '
  • 字号适当加大:'font': {'size': 15}

问题三:跨年动画卡顿(核心问题)

现象

播放动画时,每到年份切换的瞬间会明显卡顿一下,像幻灯片翻页。

原因分析

原始方案只有 6 帧(2020-2025 每年 1 帧),问题出在两个层面:

  1. 帧间数据差异过大:相邻帧之间销量数值跨度大,即使 transition.duration 设得很长,Plotly 也只是在两个离散状态之间做补间,视觉上仍有明显跳变
  2. 条形数量变化:某些游戏在某年销量为 0(未发售),下一年突然出现,Plotly 需要增删 DOM 元素,导致重绘卡顿

解决方案:插值子帧

在相邻年份之间插入多个中间帧,对销量数据做线性插值,让每帧之间的数据差异极小:

INTERP_STEPS = 10  # 每两年之间插入 10 个子帧

# 为每个游戏建立 {year: sales} 查找表
game_sales_by_year = {}
for game in all_games:
    game_sales_by_year[game] = {year: get_sales(game, year) for year in years}

# 生成插值子帧
sub_frame_list = []
for yi in range(len(years) - 1):
    y1, y2 = years[yi], years[yi + 1]
    for step in range(INTERP_STEPS):
        t = step / INTERP_STEPS  # 插值比例 0.0 ~ 0.9
        interpolated = {}
        for game in all_games:
            s1 = game_sales_by_year[game][y1]
            s2 = game_sales_by_year[game][y2]
            interpolated[game] = s1 + (s2 - s1) * t  # 线性插值
        sub_frame_list.append((label, interpolated, y1 + t))

关键设计要点:

要点 说明
固定条形数量 所有游戏在每帧都存在(销量为 0 的也保留),避免 DOM 增删
线性插值 相邻子帧差异仅为总变化量的 1/10,变化平滑
年份标注 中间帧显示整数年份(int(year_float)),不出现小数
帧数量 从 6 帧变为 51 帧(5 个间隔 x 10 子帧 + 最后 1 帧)

最佳参数组合

经过调试,最丝滑的默认参数组合为:

参数 说明
帧时长 (frame.duration) 50 ms 子帧切换极快,肉眼无法感知单帧停顿
过渡时长 (transition.duration) 500 ms 过渡远大于帧时长,Plotly 持续做补间动画
缓动函数 (easing) linear 子帧模式下线性最自然,不需要额外缓动

原理:帧时长 50ms 意味着每 50ms 推进一个子帧,但过渡时长 500ms 让 Plotly 始终在做平滑过渡动画,多个子帧的过渡会重叠衔接,形成连续不断的滑动效果。

参数关系图解

原方案(6帧,transition 200ms):

帧1 ──────────停顿──────────> 帧2 ──────────停顿──────────> 帧3
     ↑200ms过渡↑                    ↑200ms过渡↑
     剩余时间静止                     剩余时间静止
     → 像幻灯片                      → 像幻灯片

优化方案(51帧,frame 50ms,transition 500ms):

帧1 → 帧2 → 帧3 → 帧4 → 帧5 → 帧6 → 帧7 → ...
  ↘过渡↗↘过渡↗↘过渡↗↘过渡↗↘过渡↗↘过渡↗
  过渡持续重叠,视觉上连续滑动,无卡顿

问题四:导出为 MP4 视频

Plotly 原生方案的现状

Plotly 6.x 文档提到 fig.write_video(),但实测 Plotly 6.7 + Kaleido 1.2 环境下该方法尚未实现,调用会报 AttributeError

可行方案:MoviePy + Kaleido 逐帧渲染

核心思路:不依赖 Plotly 的 Frame 动画机制,而是用 Python 直接计算每一帧的数据,更新 Figure,通过 Kaleido 渲染为 PNG,再由 MoviePy 合成视频。

from moviepy import VideoClip
import io
from PIL import Image

def plotly_fig2array(fig):
    """将 Plotly figure 转为 RGB numpy 数组"""
    fig_bytes = fig.to_image(format="png", width=1200, height=720)
    buf = io.BytesIO(fig_bytes)
    img = Image.open(buf).convert("RGB")  # 关键:必须转 RGB
    return np.asarray(img)

def make_frame(t):
    """MoviePy 回调:根据时间 t 更新图表并返回帧"""
    sales, display_year = get_interpolated_sales(t)
    sorted_games = sorted(all_games, key=lambda g: sales[g])
    for i, game in enumerate(sorted_games):
        fig.data[i].x = [sales[game]]
        fig.data[i].y = [game]
        fig.data[i].marker.color = color_map[game]
    fig.layout.annotations[0].text = display_year
    return plotly_fig2array(fig)

animation = VideoClip(make_frame, duration=11.5)
animation.write_videofile("output.mp4", fps=30, codec="libx264", audio=False)

关键踩坑点

问题 原因 解决方案
write_video 不存在 Plotly 6.7 尚未实现该 API 改用 MoviePy 逐帧方案
moviepy.editor 导入报错 MoviePy 1.x 与 Python 3.13 不兼容 升级到 MoviePy 2.x,改用 from moviepy import VideoClip
视频播放显示雪花 to_image 返回 RGBA 四通道,MoviePy/H.264 需要 RGB 三通道 Image.open(buf).convert("RGB") 显式转换
ffmpeg 未安装 系统 PATH 中没有 ffmpeg pip install imageio[ffmpeg] 安装内置 ffmpeg

依赖安装

pip install plotly kaleido moviepy imageio[ffmpeg]

性能参考

分辨率 FPS 总帧数 单帧耗时 总渲染时间
160x90 5 27 ~3s ~1.5min
1200x720 5 27 ~3s ~1.5min
1200x720 30 345 ~2.5s ~15min

渲染瓶颈在 Kaleido 逐帧渲染 PNG,与分辨率关系不大,帧数是主要影响因素。

总结

Plotly 条形图赛跑动画的四个关键经验:

  1. 持久化输出:用 write_html() 替代 show(),避免临时服务器问题
  2. UI 调试控件:通过 updatemenusdropdown 类型实现参数实时切换,加速调试迭代
  3. 插值子帧消除卡顿:这是最核心的优化——仅靠调大 transition.duration 无法解决帧边界跳变,必须在数据层面插入中间帧,让每帧之间的差异足够小,再配合短帧时长 + 长过渡时长的参数组合,实现真正丝滑的动画效果
  4. 视频导出用 MoviePy:Plotly 原生 write_video 尚不可用,通过 MoviePy 的 VideoClip(make_frame) 模式逐帧渲染是当前最可靠的方案,注意 RGBA→RGB 转换和 MoviePy 版本兼容性

参考资料

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐