Canvas 插值 + Gemini 截图把 F1 历史比赛做成了实时赛道可视化

F1 Replay Timing 是一个开源 F1 赛事回放 Web 应用,156 Star,用 Next.js + FastAPI 构建。它的核心功能是把 FastF1(一个获取 F1 官方遥测数据的开源 Python 库)里的历史赛事数据,还原成一套可交互的实时回放界面:赛车在轨道上每 0.5 秒更新一次位置、完整的驾驶员遥测图表、可变速播放,以及一个比较少见的功能——把 F1 直播的计时板截图上传给它,它能自动识别当前的时间差,帮你把本地回放的进度对齐到直播进度。

项目目前支持 2024 赛季起的正赛、排位赛和练习赛,供个人非商业使用。

三个主要模块

轨道位置可视化

回放时,每辆赛车的坐标每 0.5 秒从后端获取一次,前端用平滑插值填补两帧之间的运动,让视觉上看起来是连续移动而不是跳帧。赛车图标在轨道上实时移动,同时叠加扇区时间可视化——排位赛场景下会用紫色(最快单圈)、绿色(个人最快)、黄色(正常)标出每个扇区的状态,和 F1 官方计时系统的颜色体系一致。

计时板与遥测数据

左侧计时板显示当前顺位、各车间隔时间、轮胎种类和换胎次数。右侧可以切换到任意驾驶员的遥测图表,包括速度、油门开度、刹车力度和当前挡位,数据来源于 FastF1 拿到的官方遥测记录,不是模拟数据。

此外还有天气和赛道状态面板,显示气温、路面温度、风速风向和当前旗帜状态。

广播同步

这个功能解决的是一个具体场景:你在看 F1 直播,但想同时打开本地回放查看详细数据。直播的进度和本地回放的进度不一致,需要手动对齐。

F1 直播画面通常会在角落显示计时板,上面有各驾驶员的时间差。F1 Replay Timing 支持两种同步方式:

  • 截图上传:对着直播画面的计时板截一张图,上传给应用,它通过图像分析识别出当前的时间差数据,自动计算本地回放应该快进或跳转到哪个位置。v1.1.0 新增了剪贴板粘贴支持,截图后直接 Ctrl+V 就能贴入,不需要先保存文件。
  • 手动输入:如果截图识别不准确,也可以直接输入直播与本地之间的秒数差,手动对齐。

核心代码讲解

一、WebSocket 驱动的回放循环

回放的服务端核心是 backend/routers/replay.py 里的一个 WebSocket 端点。后端把每一帧(包含所有车辆的位置、间隔、遥测等数据)预先计算好存在 JSON 文件里,播放时按 0.5 秒间隔逐帧推送给前端。

while True:
    if playing and frame_index < len(frames):
        await websocket.send_json({"type": "frame", **prepare_frame(frames[frame_index])})
        frame_index += 1
        if frame_index >= len(frames):
            playing = False
            await websocket.send_json({"type": "finished"})
            continue
        # base_interval = 0.5 秒,speed 控制倍速
        remaining = base_interval / speed
        while remaining > 0 and playing:
            chunk = min(remaining, 0.05)
            await check_command(chunk)  # 每 50ms 检查一次命令
            remaining -= chunk
    else:
        await check_command(1.0)

速度控制的实现很直接:remaining = 0.5 / speed,2x 速度时两帧之间等 0.25 秒,20x 时等 25 毫秒。等待期间每 50ms 检查一次客户端发来的命令(play/pause/seek/speed),这样倍速切换和暂停可以立即生效,不需要等当前帧间隔结束。

前端 useReplaySocket.ts 通过 WebSocket 消息控制回放:

const play  = () => { send("play");        setState(s => ({ ...s, playing: true })); };
const pause = () => { send("pause");       setState(s => ({ ...s, playing: false })); };
const seek  = (time: number) => send(`seek:${time}`);
const setSpeed = (speed: number) => send(`speed:${speed}`);

服务端和客户端之间没有心跳或确认机制,靠 WebSocket 本身的连接状态管理,设计相对简洁。

二、赛车位置的平滑插值

后端每 0.5 秒发一帧,如果前端每次直接把赛车跳到新坐标,画面会明显卡顿。TrackCanvas.tsx 用线性插值解决这个问题:

// 插值持续时间故意比帧间隔(500ms)长,确保下一帧到达时上一段动画尚未结束
const BASE_INTERP_MS = 750;
 
// 新帧到来时,记录当前视觉位置作为起点,新坐标作为终点
entry.prevX = entry.prevX + (entry.targetX - entry.prevX) * t; // 当前视觉位置
entry.targetX = drv.x;  // 新目标位置
entry.startTime = now;
entry.duration = BASE_INTERP_MS / Math.max(speed, 0.25);
 
// requestAnimationFrame 循环里,每帧计算当前插值位置
const t = Math.min(elapsed / entry.duration, 1);
const x = entry.prevX + (entry.targetX - entry.prevX) * t;
const y = entry.prevY + (entry.targetY - entry.prevY) * t;

插值时长设成 750ms 而不是 500ms,目的是让两段动画在时间上有重叠——第二段动画开始时,第一段还没完全结束,视觉上就不会出现停顿再启动的感觉。倍速播放时 duration 等比缩短,保证高速回放时赛车也能跟上数据更新节奏。

三、广播同步:视觉模型 + 帧匹配

截图同步是项目里技术上最有意思的部分,分两步:用视觉模型识别截图里的计时数据,再用识别结果去历史帧里找最匹配的时间点。

第一步:用 Gemini 识别截图

sync.py 把截图发给 Gemini 2.0 Flash(通过 OpenRouter 调用),提示词明确要求返回结构化 JSON:

VISION_MODEL = "google/gemini-2.0-flash-001"
 
# 提示词要求 Gemini 识别:当前圈数、间隔显示模式(LEADER/INTERVAL)、每位车手的位置和时间差
# 并统一换算成"与领先者的累计时间差"格式
payload = {
    "model": VISION_MODEL,
    "messages": [{"role": "user", "content": [
        {"type": "text", "text": EXTRACT_PROMPT},
        {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}},
    ]}],
    "temperature": 0,  # 关闭随机性,要求确定性输出
}

提示词里有一个处理细节:F1 直播的计时板有两种显示模式——LEADER 模式显示每辆车与领跑者的累计差,INTERVAL 模式显示与前一辆车的间隔差。Gemini 被要求无论识别到哪种模式,都换算成 LEADER 模式再返回,方便后续统一比较。

第二步:在历史帧里找最佳匹配

识别到当前圈数和各车时间差后,_match_frame 函数把这组数据和历史帧逐一比对,找误差最小的那帧:

def _match_frame(frames, extracted):
    target_lap = extracted.get("lap", 1)
    # 先过滤出前后一圈范围内的候选帧,减少比对量
    candidate_frames = [
        (i, f) for i, f in enumerate(frames)
        if abs(f.get("lap", 0) - target_lap) <= 1
    ]
    best_score = float("inf")
    for idx, frame in candidate_frames:
        score = 0.0
        for abbr, target_gap in target_gaps.items():
            frame_gap = get_driver_gap(frame, abbr)
            if frame_gap is None:
                score += 50.0  # 找不到这位车手,加惩罚分
            else:
                score += abs(frame_gap - target_gap)  # 时间差的绝对误差
        if score < best_score:
            best_score = score
            best_idx = idx
    return frames[best_idx]

匹配逻辑用时间差的绝对误差加总作为得分,分数越低越匹配。找不到某位车手时加 50 秒惩罚,防止因为截图里车手数量不完整而误匹配到错误的帧。

安装与使用

前提条件

推荐用 Docker 方式运行,只需要安装 Docker Desktop。手动安装需要 Python 3.10 及以上版本和 Node.js 18 及以上版本。

Docker 启动(推荐)

克隆仓库后,在项目根目录执行:

git clone https://github.com/adn8naiagent/F1ReplayTiming.git
cd F1ReplayTiming
docker compose up

启动后访问 localhost:3000 即可。

数据处理

应用不内置赛事数据,需要先通过 FastF1 从官方接口拉取并预处理。有两种方式:

方式说明耗时
按需处理在界面上选择赛事后自动触发,首次加载时处理1–3 分钟/场
批量预计算通过 CLI 命令提前处理整个赛季的所有场次2–3 小时/赛季

如果只是偶尔看几场,按需处理更方便;如果想随时切换查看任意场次,提前跑一遍批量处理能节省等待时间。

播放控制

支持 0.5x、1x、2x、5x、10x、20x 倍速,以及按圈数导航,可以直接跳到某一圈的起点。v1.1.0 新增了画中画模式,可以把某个面板弹出为浮窗,方便同时查看多个视图。

写在最后

F1 Replay Timing 做的事情比较具体:把 FastF1 的遥测数据转成一套可交互的回放界面。三处核心实现——WebSocket 帧推送、requestAnimationFrame 插值动画、视觉模型驱动的帧匹配——都没有依赖复杂的库,代码量不大,逻辑清晰,对于想了解这类实时数据可视化应用怎么做的开发者来说,代码本身值得参考。

项目声明仅供个人非商业使用,数据来源依赖 FastF1 对 F1 官方接口的访问,使用前需要了解相关限制。

GitHub 地址:https://github.com/adn8naiagent/F1ReplayTiming (opens in a new tab)