見出し画像

npb+のリアルタイムデータから"飛ばないボール"を調べてみた



自己紹介+注意事項

初めまして、Gozaと申します。
神戸大学に通う学部新4年生で、専攻は物理学です。大学の講義で学んだ
"流体力学"と呼ばれるものに興味を持ちまして、自由研究として
野球の投球シミュレーターの開発やボールの研究を行っています。
npb+のおかげでオープン戦からデータ解析もできるようになったので
その研究経過などをここでは話していこうと思います。
開幕して1ヶ月くらい経ったら公表しようかと思ってたのに、
開幕戦で思ったよりホームランが出たので焦っている。

初めてnoteを書くので勝手がわかっておらず、見づらいかもしれません。
野球経験はほぼなく、ど素人なので技術・感覚的なことはわかりません
また、あくまでも独学で行っていますので下記で紹介する物理モデルや考察には粗さがあるということを前提とした上で、ご一読ください
npb+の画像使用や、その他画像で使用上の問題があった場合は速やかにお伝えください

以下にこれまでのポートフォリオ(研究記録)を載せておきます。
追記:自分の研究が進んで運動方程式が新しくなりました、扱っている物理モデルは少し古いものになります

概要・準備:"飛ばないボール"について

概要:NPBは"飛ばないボール"を使っているのか?

WBCで日本がベスト8で敗退した原因について、XではNPBが
「飛ばないボール」を使っていることが原因だという声が散見されます。
近年、NPBのホームラン数は減少傾向にあり、特に2024年は年間で1000本を切るという、2011年・2012年の統一球と並ぶホームラン数の少なさでした。「飛ばないボール」を検証するには以下の2点に注目する必要があります。

準備1:反発係数

反発係数は打球速度に影響のあるパラメータであり、NPBでは0.4134、MLBでは0.546程度だと言われており、計測方法が同じであれば元々NPBの方が打球速度が出にくいとされています。
よって近年急にホームラン数が低下した要因としてはあまり考えられません(反発係数はフライ打球の打球速度分布を見るとある程度推測ができるはずなので、いずれ確認したい)。

準備2:抗力係数(Cd)・揚力係数(Cl)

こちらが「飛ばないボール」の原因として最も可能性が高いものです。
空気によってボールが引っ張られる力である空気抵抗とスピンによってボールが影響を受ける力である揚力(マグヌス力)はボールの軌道を考える上で無視することができず、これらの値が大きいほど、空中で激しく失速します。
それらの係数であるCdとClはボールの密度や大きさだけでなく、表面上のざらつきなどによっても大きく変化します。
NPBのボールはMLBよりざらざらしていて、縫い目が高いと言われているためCd、ClがMLBよりも大きくなっているのではないかと予想しました
(詳しくはポートフォリオに解説記事があります)。

実験方法・予想物理モデルの説明(飛ばしてOK)

ここから、実験方法や、用いる物理モデルの紹介を行います。
実験方法はまだしも、物理モデルはほとんどが私が作ったものであり、難解な流体力学のモデルを使う以上、近似式を導入する他なく、だいぶ訳のわかりづらいものになっています。
どうしてこうなったのか、もし気になればポートフォリオをご覧ください。

1:実験環境

Pytesseractを用いてNPB公式アプリ「npb+」の打球からボールが飛んでいないか検証します。
打球のうち、飛距離が50m、打球角度15°以上のインプレーで起きたフライに絞ります(スタンドに入ったものは飛距離がブレるので入れない)。
対象球場は気候変動の少ないドーム球場(PayPayドーム及びバンテリンドームが理想)ではありますが、今回はテストとしてオープン戦や二軍戦(+3/27と28のソフバンvs日ハム)のデータを抜き取りました。
MLBはあまり詳しくないのですが、ドーム球場ということでタンパベイレイズのトロピカーナ・フィールドから取り出しています。
⇧調べたら、トロピカーナ・フィールドは自分が収集した2025年シーズンではほとんど使われていないことが判明したため、実質ジョージ・M・スタインブレナー・フィールドのデータになります….知らなかった…

図1:解析環境

2:予想物理モデル

Cd、Clについてですが本来Clは回転数から算出するもので、回転数や向きが分かればClが分かり、Clが分かれば運動方程式よりCdを導けます。
ただ、Statcast にもnpb+にも打球の回転数や向きが記載されていないため、不可能です。
そこで、回転数が記録されているStatcastのピッチングデータからCdを求め、Clはいくつかの回転数を用意し、その時々で変化する値として考えました。
偉大なるAlan Nathan教授の文献を参考にすれば、Cdには回転数と中程度の正の相関があることがわかったらしく、
調べると航空力学でもそれに似たような力として「誘導抗力」と呼ばれるものがありましたので、それを参考に単回帰モデルを作りました。

図2:Cl^2に依存するCdモデル

以上の結果と、以前ポートフォリオサイトで学習した空気抵抗・揚力の特徴をもとに作り上げた式が以下の通りです。


図3:抵抗係数・揚力係数のモデル

流石にピッチングデータをそのままバッティングデータに代入してもずれるので、バッティングデータと比較して最もズレが少なくなるように
調整項のオフセットを導入しました。

図4:オフセットの最適化

このモデルは思っていたよりも実際の打球を表すことができているようで、バレルゾーン(長打、強いてはホームランになりやすい打球速度、角度帯)をうまく再現できていました。
バレルゾーンの打球速度の最低ラインは158km/h、打球角度は26~30°程
速度が速くなるほど、角度帯は広くなります。
この近似式を用いた際の推測飛距離について調べると、以下の通りです。

図5:バックスピン量と推測飛距離の関係

球場によってスタンドまでの距離が違いますが、
大体両翼100m前後で中堅120m前後であるから、フェンスの高さも考慮するとこのモデルでは158km/h辺りからようやくホームランになる可能性が出てくるようになっています
以上の結果からある程度の信頼性があると踏みまして、
細かな説明は一旦置き、この近似式を使って理想的な飛距離を算出します。
詳しい条件は使っている基本的なコードを最後に載せておきますのでご確認ください(AIのコピペもあったりしてあまり綺麗ではないかも)。

実験結果・考察

打球速度相応の飛距離が出ているか?

前述の通り、"急に"ボールが飛ばなくなったのであればそれは反発係数ではなく、空気抵抗やスピンによる抗力により途中で失速しているからだと考えられます。
これを確かめるにはMLBと同じ打球速度で放った時、その飛距離の分布で明らかにNPBのボールが飛んでいないことを示せば良いと思いました。

上記で用意した物理モデルに、実際の打球のデータ(角度、打球速度など)
を当てはめ、回転数で場合分けすることにより算出された
予測飛距離と実際の飛距離の差を確認しました。

図6:打球速度と飛距離誤差の比較(打球角度で色分け)

結果は上の通りです。私の作ったモデルでは、打球角度が上がるほど、飛距離が出にくいモデルになっているようで、角度で色付けするとその特徴がよくわかります(めっちゃばらけてるように見えるが、理論モデルはバックスピン前提なので、実際バックスピンで飛んでいないものはマイナスの値になりがち)。

さて、それぞれの分布を確認した時、違いがあるように見えるでしょうか?
NPBのデータ数が少なすぎるので納得性はないかもしれませんが、
打球速度で比較したり、回転数で場合分けを行ったり、角度で色付けをしてみたりしても
物理モデルとの飛距離の差はNPBもMLBも同じようなばらつきをしているように見えます

つまり空気抵抗やスピンによる影響はMLBと変わらない可能性が高いです。

今年から"飛ぶボール"になった?

とはいえ、今年からボールが飛ぶようになっている可能性も否めません。
ただnpb+は2026年から本格的にスタートしたサービスのためこれまでの打球のデータを一般人が拾えるようなものではなく、比較は難しいです。
しかしその中でも2025年、full-countの記事で興味深いものがありました。

https://full-count.jp/2025/03/15/post1717817/

この記事では東京ドームで鈴木誠也選手(カブス)の打球初速180km/h、
角度20°のあたりがセンターフライ
となったという記録が残されています。
当時NPBのボールが飛ばないというのを表す記事として話題になりました。

一方で今年はこんなホームランが開幕戦で生まれました。
3/27、PayPayドームでのソフトバンクvs日ハム戦、
万波中正選手のホームランです。

図7:万波中正選手のホームラン

打球角度は高いですが、同じ180km/h付近で距離は132mをマークしています。東京ドームは中堅122mですので、もし万波選手が東京ドームでセンター方向にこのパワーを出せていたらフェンス直撃以上の結果になっていたと思います。
物理モデルを当てはめると、以下の通り、

図8:物理モデルで見た万波選手のホームラン(回転軸0°でバックスピン、90°でサイドスピン)

綺麗なバックスピンで打てなくとも、万波選手のホームランの飛距離は十分実現し得ると考えることができます。

オープン戦では3/10、バンテリンドームでの中日vsヤクルト戦でも
ミゲルサノー選手が近しい打球速度と角度、飛距離を叩き出しており、

図9:ミゲルサノー選手のホームラン

日本が誇るMIZUNOの野球ボールの精巧な作りに伴う再現性の高さを実感させられます。
鈴木選手のあたりも物理モデルに当てはめると以下の通りであり、

図10:物理モデルで見た鈴木誠也選手のフライアウト

綺麗なバックスピンである時、多くの回転数で中堅フェンスまで到達することがわかります。

鈴木選手のあたりがアウトになってしまったのは
「擦ってしまい、バックスピンがかからなかった」
(センターフライなので可能性薄)

もしくは
「去年のNPBのボールが飛ばなかった」
のいずれかの可能性が高いと考察します。

おまけ:今年も"飛ばないボール"?

ここまで、今年のボールは飛ぶのではないかという話をしていましたが、
つい最近「飛ばないボール」疑惑の映像がXで話題になりました。
2026年オープン戦3/22、バンテリンドームでの中日vsロッテ戦、
5回裏ミゲルサノー選手のレフトフライです。
映像を転載するのは流石に気が引けるので載せませんが、
打った瞬間確信とも言えるあたりがレフトフライになった映像は衝撃的でした。
ただ、私は妥当な結果だと考えています。

図11:ミゲルサノー選手のレフトフライ

まず、打球速度ですが、151km/hであり、理想的な角度ではなったとしてもホームランになる確率は低そうです。
加えて注目したいのは、ボールの軌道です。

突然ですが、ゴルフボールの軌道についてご存知でしょうか?
自分はゴルフ自体、「二階堂地獄ゴルフ」をちょっと読んだ程度で全然詳しくないのですが、ゴルフボールには
フック・フェード・ドロー・スライスの4タイプがあるそうです。

図12:ゴルフボール軌道4タイプ(「ゴルフの学校」(https://golf-gakko.com/)より)

ゴルフサイト「ゴルフの学校」様によると、
フックとスライスは失敗なのでともかく、
フェードはコントロール重視で、ドローがクラブの面にしっかり当たりやすく飛距離が出やすいとのこと。
あるスポーツの考えが別のスポーツでも当てはまるかはなかなか難しいところではありますが、ボールの軌道からしても、飛距離からしても
私はフェード・ドローの関係が流し打ち・引っ張りの関係に類似していると感じました。

改めて、サノー選手のレフトフライを振り返ると、打球がフェードしているように感じます。
引っ張っているのにも関わらず流し打ちしているような軌道です。
右方向に加速する強烈なサイドスピンがかかっており、そのスピンの分だけ空気抵抗が増加、その上で打球速度や角度がバレルゾーン内ではないのであれば、100m程度しか飛ばないのも納得できます。

最後にサノー選手の打席をリアタイしておらず、打球角度がわからなかったのですが、大体30°あたりと推測して作った物理モデルを置いておきます。

図13:物理モデルで見たサノー選手のフライアウト

終わりに

最後に、なぜ自分がこの自由研究をしているかについて話します。

2026年シーズンが始まり、野球の季節が到来した一方、
WBCの敗退やロス五輪出場の危機なども相まって私はこのままでは日本野球が衰退していくと感じました。
ただし、それを訴えかけるとしてもちゃんとした根拠や考えを持たないと、他所から見ればただ泣き喚いているだけで意味がありません。

野球中継ではカメラの角度や本人の態度などを鑑みて、視聴者はその打球の期待度を考えることができますが
それが必ずしも期待する内容になるかはわかりません
不自然な現象が起きたらむやみやたらにおかしいと決めつけるのではなく、どうしてそうなったのか、本当におかしいと言えるのか、冷静に考察し、
判断する必要があると強く主張します

個人的な活動なので長く研究するつもりはありませんが、
私はこれから約1ヶ月できる限りデータをとって、シミュレーションして、
自分なりの考えを見つけていこうと思います。
最後までご覧戴き、ありがとうございました。
(使ったデータ(csv)を載せようとしたけど重たい😭どうすればいいんだ😭)

実験で使用した基本コード(Python)

from google.colab import drive
drive.mount('/content/drive')
from pybaseball import statcast
import os
import pandas as pd
mlb_3months_path="/content/drive/MyDrive/mlb_statcast_3months.csv"

print("MLBデータを収集します。")
if os.path.exists(mlb_3months_path):
  df_mlb=pd.read_csv(mlb_3months_path)
else:
  df_mlb=statcast(start_dt="2025-04-01",end_dt="2025-06-30")
  df_mlb.to_csv(mlb_3months_path,index=False)

print("収集完了しました。")
#物理モデルや計算式まとめるところ
from matplotlib.lines import lineStyles
import pandas as pd
import numpy as np
from scipy.stats import linregress
from google.colab import drive
drive.mount('/content/drive')
import matplotlib.pyplot as plt
import japanize_matplotlib
from pybaseball import statcast
from scipy.integrate import solve_ivp #微分方程式ツール
csv_file = "/content/drive/MyDrive/npb_dome_data.csv" #集めたデータを入れてね
df_npb = pd.read_csv(csv_file)

#定数や使う物理量
rho = 1.225
radius = 0.0368
eta = 1.789e-5
constFactor = (rho * 2 * radius) / (eta * 3.6) #レイノルズ数変換の時にこれと速度かければ出せる
m = 0.145
g = 9.8
area = np.pi * radius**2
ft_to_m = 0.3048 #フィートからメートルへの変換
mp_to_km=1.60934 #マイルからキロメートルへの変換
cd_offset=0.12 #最適化して出した

#statcastのピッチングデータをもとにCdの基本モデルを導出
print("【1】statcastのピッチングデータからCdの基本モデルを作ります。")
print("上のコードを実行していることを確認してください。")


#ドーム球場のタンパベイレイズで尚且つバックスピンでSSWの小さい4seamを選択
df_dome=df_mlb[(df_mlb["home_team"]=="TB")&(df_mlb["pitch_type"]=="FF")].copy()

cols=["release_speed","release_spin_rate","vx0","vy0","vz0","ax","ay","az"]
df_physics=df_dome[cols].dropna().copy()
#dropna()で一つでも欠損(NA)がある行を削除

#statcastはy軸がキャッチャーかピッチャーの方向を考慮して計算
df_physics["v_m_s"]=np.sqrt(df_physics["vx0"]**2+df_physics["vy0"]**2+df_physics["vz0"]**2)*ft_to_m
df_physics["ax_m"]=df_physics["ax"]*ft_to_m
df_physics["ay_m"]=df_physics["ay"]*ft_to_m
df_physics["az_m"]=df_physics["az"]*ft_to_m

#ma_y=-1/2*rho*v^2*area*C_dよりC_dを導出
df_physics["force_common"]=2*m/(rho*area*df_physics["v_m_s"]**2)
#拾うデータがa_y<0より、絶対値化する
df_physics["Cd"]=np.abs(df_physics["ay_m"])*df_physics["force_common"]

#揚力に関しても同じようにやりたいが、上下(z軸)左右(x軸)考慮する必要があるので
#ma_x=1/2*rho*v^2*area*Cl_xとma_z=-mg+1/2*rho*v^2*area*Cl_zから三平方で導出
df_physics["Cl"]=np.sqrt(df_physics["ax_m"]**2+(df_physics["az_m"]+g)**2)*df_physics["force_common"]

#もしくはスピンパラメータから導出(相関が弱くなるので今回は使わない)
df_physics["omega_rad_s"]=df_physics["release_spin_rate"]*(2*np.pi/60)
df_physics["S"]=(radius*df_physics["omega_rad_s"])/df_physics["v_m_s"]
df_physics["Cl_theory"]=0.6564*(df_physics["S"]**0.7)

#あり得る値だけ考える(0.2<cd<0.6, 0<cl<0.7)
df_valid=df_physics[(df_physics["Cd"]>0.2)&(df_physics["Cd"]<0.6)
                    &(df_physics["Cl"]>0)&(df_physics["Cl"]<0.7)].copy()

print(f"{len(df_valid)}球のフォーシームを解析します。")

#平均球速
mean_speed_mph=df_valid["release_speed"].mean()
V_REF=mean_speed_mph*mp_to_km
RE_REF=V_REF*constFactor
#回帰分析(Cd=Cd0+k*Cl^2)を行う
df_valid["Cl_squared"]=df_valid["Cl"]**2

#最小二乗法
slope, intercept, r_value, p_value, std_error=linregress(df_valid["Cl_squared"], df_valid["Cd"])
K_MLB=slope#k値
CD_REF_MLB=intercept


print(f"MLBモデルの回帰結果")
print(f"平均球速:{V_REF}")
print(f"Cd_0:{CD_REF_MLB:.4f}")
print(f"k:{K_MLB:.4f}")
print(f"相関係数r:{r_value:.4f}")
#おそらく相関係数は0.5以下と弱い値を取るが、
#mlbのボールの作りの荒さやその他環境を考慮した上で中程度の相関と解釈する


print("【2】解析で用いる計算式を準備します。")
#C#で以前作ったプロット
def drag_crisis(Re, Cd_laminar=0.5, Cd_min=0.25, Re_trans=1.0e5, k=10, rise_factor=0.15):
    decay = (Cd_laminar - Cd_min) / (1 + np.exp(k * (Re - Re_trans) / Re_trans))
    rise = rise_factor * np.log10(np.maximum(1, Re / Re_trans))
    return Cd_min + decay + rise

#シミュレーションから得た改良版
def drag_crisis_remaster(re_val):
    Cd_laminar = 0.5
    Re_trans = 1.0e5
    k_trans = 10
    rise_factor = 0.15
    decay = (Cd_laminar-CD_REF_MLB) / (1 + np.exp(k_trans * (re_val - Re_trans) / Re_trans))
    rise = rise_factor * np.log10(np.maximum(1, re_val / Re_trans))
    return decay + rise

#調整項(オフセット)を追加
def baseball_calculate_perfect(t, state, rpm, cd_offset,tilt_angle=0):
    x, y, vx, vy = state
    v_ms = np.sqrt(vx**2 + vy**2)
    v_kmh = v_ms * 3.6

    #Cl
    omega_rad_s = rpm * (2 * np.pi / 60)
    S = (radius * omega_rad_s) / v_ms
    cl_current = 0.6564 * (S ** 0.7)
    #回転軸を設定してシミュレーションできるように
    #0°でバックスピン、45°でフック
    cl_vertical=cl_current*np.cos(np.radians(tilt_angle))

    #Cd (Drag Crisis + オフセット + スピンペナルティ)
    Re = v_kmh * constFactor
    delta_cd_base = drag_crisis_remaster(Re) - drag_crisis_remaster(RE_REF)


    cd_base = CD_REF_MLB + delta_cd_base + cd_offset
    cd_total = cd_base + K_MLB * (cl_current**2)

    #運動方程式
    Fd = 0.5 * cd_total * rho * area * v_ms**2
    Fl = 0.5 * cl_vertical * rho * area * v_ms**2
    ax = (-Fd * (vx/v_ms) - Fl * (vy/v_ms)) / m
    ay = (-m*g - Fd * (vy/v_ms) + Fl * (vx/v_ms)) / m
    return [vx, vy, ax, ay]

def hit_ground(t, state, rpm, cd_offset,tilt_angle=0): return state[1]
hit_ground.direction = -1
hit_ground.terminal = True

#引数に cd_offset を追加し、argsで微分方程式に渡す
def simulate_perfect(v0_kmh, angle_deg, rpm, cd_offset,tilt_angle=0):
    v0_ms = v0_kmh / 3.6
    rad = np.radians(angle_deg)
    y0 = [0.0, 1.0, v0_ms * np.cos(rad), v0_ms * np.sin(rad)]
    res = solve_ivp(baseball_calculate_perfect, [0, 15], y0, args=(rpm, cd_offset,tilt_angle), events=hit_ground, max_step=0.03)
    return res.y[0][-1]

print("【3】フライの条件を設定し、mlbとnpbのデータを引き出します。")
#MLB
df_hits_mlb = df_mlb[(df_mlb['home_team'] == 'TB') &
                     (df_mlb['type'] == 'X') & #Sストライク,Bボール,Xインプレー
                     (df_mlb['hit_distance_sc'].notnull()) &
                     (df_mlb['events'] != 'home_run')].copy()

df_hits_mlb['Speed_kmh'] = df_hits_mlb['launch_speed']*mp_to_km
df_hits_mlb['Angle_deg'] = df_hits_mlb['launch_angle']
df_hits_mlb['Distance_m'] = df_hits_mlb['hit_distance_sc']*ft_to_m

df_fly_mlb = df_hits_mlb[(df_hits_mlb['Distance_m'] > 50) &
                           (df_hits_mlb['Angle_deg'] > 15) ].copy()
#1ヶ月分だけ取り出す(一旦日付を文字から数字へ)
df_fly_mlb["game_date"]=pd.to_datetime(df_fly_mlb["game_date"])

start_date = '2025-04-01'
end_date   = '2025-04-30'

df_valid_mlb=df_fly_mlb[
    (df_fly_mlb["game_date"]>=start_date)&
    (df_fly_mlb["game_date"]<=end_date)].copy()
print(f"NPBとの比較で{start_date}~{end_date}(計{len(df_valid_mlb)}件)取り出しました。")
#NPB
df_valid_npb = df_npb

print("【4】センターフライに絞り、推測の近似式の整合性を考えます。")
df_cf_fly=df_fly_mlb[(df_fly_mlb["hit_location"]==8.0)& #センターが捕球
                     (df_fly_mlb["events"]=="field_out")&
                     (df_fly_mlb["bb_type"]=="fly_ball")].copy() #フライアウト
print(f"センターフライ{len(df_cf_fly)}件を拾いました。")

print("設定完了しました、必要な関数は適宜このコードに更新してください。")


いいなと思ったら応援しよう!