Draw Baseball Fields and Spray Charts in matplotlib: baseball-field-viz

Published: (February 22, 2026 at 05:52 PM EST)
4 min read
Source: Dev.to

Source: Dev.to

Background

pybaseball’s built‑in spraychart() is convenient, but it doesn’t support overlaying heatmaps—making it hard to visualize batted‑ball density by zone.

To use seaborn’s kdeplot or histplot on top of a baseball field you have to draw the field manually in Matplotlib. Writing the coordinate‑transform and field‑drawing code every time is tedious, so I packaged it.

pip install baseball-field-viz

PyPI:
GitHub:

What baseball-field-viz provides

Three functions for Statcast visualisation:

from baseball_field_viz import transform_coords, draw_field, spraychart
FunctionDescription
transform_coords(df)Convert Statcast hc_x/hc_y to feet (home plate at origin)
draw_field(ax)Draw a baseball field on a Matplotlib Axes
spraychart(ax, df, color_by='events')One‑liner combining the two above

Usage

Quickstart

import matplotlib.pyplot as plt
from baseball_field_viz import spraychart

fig, ax = plt.subplots(figsize=(10, 10))
spraychart(ax, df, color_by='events', title='Player — Batted Balls')
plt.show()

With color_by='events':

  • home run → red
  • triple → orange
  • double → blue
  • single → green

Overlay a heatmap (the key advantage)

Since draw_field returns the Axes object, you can layer any Matplotlib/Seaborn plot on top:

import seaborn as sns
from baseball_field_viz import draw_field, transform_coords

df_t   = transform_coords(df[df['hc_x'].notna()])
hits   = df_t[df_t['events'].isin(['home_run', 'double', 'triple', 'single'])]
outs   = df_t[~df_t['events'].isin(['home_run', 'double', 'triple', 'single'])]

fig, axs = plt.subplots(1, 2, figsize=(16, 8))

# Hits heatmap
draw_field(axs[0])
sns.kdeplot(data=hits, x='x', y='y', ax=axs[0],
            cmap='Reds', fill=True, alpha=0.6)
axs[0].set_xlim(-350, 350)
axs[0].set_ylim(-50, 400)
axs[0].set_title('Hits Heatmap')

# Outs heatmap
draw_field(axs[1])
sns.kdeplot(data=outs, x='x', y='y', ax=axs[1],
            cmap='Blues', fill=True, alpha=0.6)
axs[1].set_xlim(-350, 350)
axs[1].set_ylim(-50, 400)
axs[1].set_title('Outs Heatmap')

plt.tight_layout()
plt.show()

Applied to WBC 2026 Roster Players

I published a Kaggle notebook using this library with the WBC 2026 Scouting dataset—MLB regular‑season Statcast data (2024‑2025) for players on WBC 2026 rosters across all 18 countries. (Note: this is not WBC game data, but MLB data for WBC‑eligible players.)

Kaggle Notebook:

All 18 countries — overview spray chart

Using draw_field + per‑country scatter with the tab20 colormap:

from baseball_field_viz import draw_field, transform_coords
import matplotlib.cm as cm
import numpy as np

hit_events = ['home_run', 'double', 'triple', 'single']
hits = transform_coords(df[df['hc_x'].notna() & df['events'].isin(hit_events)])

country_list = sorted(hits['country_name'].unique())
colors      = cm.tab20(np.linspace(0, 1, len(country_list)))
color_map   = dict(zip(country_list, colors))

fig, ax = plt.subplots(figsize=(12, 12))
draw_field(ax)

for country in country_list:
    subset = hits[hits['country_name'] == country]
    ax.scatter(subset['x'], subset['y'],
               c=[color_map[country]], alpha=0.35, s=12,
               label=f"{country} ({len(subset)})")

ax.legend(loc='upper right', fontsize=8, ncol=2)
plt.show()

Top 4 countries comparison

spraychart() makes per‑country grids straightforward:

top_countries = ['USA', 'Dominican Republic', 'Venezuela', 'Japan']
fig, axs = plt.subplots(2, 2, figsize=(16, 14))

for ax, country in zip(axs.flat, top_countries):
    df_c = df[df['country_name'] == country]
    spraychart(ax, df_c, color_by='events', title=country)

plt.tight_layout()
plt.show()

Japan — hits vs. outs heatmap

The KDE overlay reveals zone‑level tendencies that scatter plots can’t:

df_jpn_t = transform_coords(df[df['country_name'] == 'Japan'][df['hc_x'].notna()])
hits_jpn = df_jpn_t[df_jpn_t['events'].isin(hit_events)]
outs_jpn = df_jpn_t[~df_jpn_t['events'].isin(hit_events)]

fig, axs = plt.subplots(1, 2, figsize=(16, 8))

draw_field(axs[0])
sns.kdeplot(data=hits_jpn, x='x', y='y', ax=axs[0],
            cmap='Reds', fill=True, alpha=0.6)

draw_field(axs[1])
sns.kdeplot(data=outs_jpn, x='x', y='y', ax=axs[1],
            cmap='Blues', fill=True, alpha=0.6)

plt.show()

WBC 2026 Scouting Dashboard

The dataset also powers an interactive dashboard:

Dashboard:

Per‑player batting and pitching stats for all 18 countries, built from the same Statcast data.

v0.2.0 – Strike Zone Support

Version 0.2.0 adds two new functions:

FunctionDescription
draw_strike_zone(ax, sz_top=3.5, sz_bot=1.5)Draw strike‑zone rectangle in plate_x/plate_z coordinates
pitch_zone_chart(ax, df, color_by='pitch_type')Plot pitch locations with auto‑sized strike‑zone overlay

Strike Zone

from pybaseball import statcast_pitcher
from baseball_field_viz import pitch_zone_chart
import matplotlib.pyplot as plt

df = statcast_pitcher('2025-03-01', '2025-10-31', 592789)  # Yoshinobu Yamamoto

fig, ax = plt.subplots(figsize=(6, 6))
pitch_zone_chart(ax, df, color_by='pitch_type',
                 title='Yamamoto 2025 — Pitch Locations')
plt.show()

Statcast includes per‑pitch sz_top/sz_bot columns (Hawk‑Eye measured, not height‑derived).
When present in the DataFrame, pitch_zone_chart uses their mean automatically — so the strike zone reflects each batter’s actual stance, not an estimate.

Installation

pip install baseball-field-viz

Requirements

  • Python 3.9+
  • matplotlib ≥ 3.5
  • numpy ≥ 1.21
  • pandas ≥ 1.3

Summary

  • draw_field(ax) + spraychart() replace boiler‑plate Statcast visualization code.
  • Direct Axes access enables heatmap overlays not possible with pybaseball’s spraychart().
  • Tested with WBC 2026 Statcast data across 18 countries.

PyPI:
GitHub:

0 views
Back to Blog

Related posts

Read more »