言語 / Language: 日本語 · English

↑ 自由に動く 2 本のラケット(赤・青)が、相手のコートで 1 度跳ねた球を運動方程式で狙って返す。最初の 3 秒を 1/5 速で。続いたことは見れば分かる。この記事の主役は、ラリーの本数が知覚・予測・制御の成績表になっていることです。
卓球の球は 2.7 g で、時速 100 km 近くで飛び、台で跳ね、毎分 3,000 回転で回ります。空気の抵抗で減速し、回転のせいで曲がり(マグヌス力)、台に当たると回転が跳ね返り方を変えます。放物線で追う追跡器は、跳ねる前までは合っていて、跳ねた後の着地点を 20 cm 外します。
ロボット卓球の先行研究は、複数の高速カメラで球を三角測量し、抵抗とマグヌスを入れた運動方程式で跳ねを越えて予測し、模様から回転を測ります。この記事は、その一式を numpy だけで作り、それぞれの段に真値つきの門を立てたものです。
(i) 定理が門になるか、(ii) 閉形式の構造を仮定しない第 2 実装が真値になるか、(iii) 公表値(規格・測定値)があるか。 どれも満たさないものは書かない。使った式で答え合わせをするのは禁止。
自動運転のシリーズ(こちら)と同じ基準です。違うのは題材が「跳ねる・滑る・回る」で、摩擦係数と反発係数が主役になること。けん玉(ひもと皿)も同じ道具で扱えるので、この系列で続けます。
| 回 | 何を測るか | 門(真値の出どころ) |
|---|---|---|
| 1 | 球は跳ねる —— 追跡・三角測量・跳ねを越える予測・スピン・ラリー | 真空の閉形式 / 接触点まわりの角運動量保存 / 頂点の等比 e^{2k}h₀ / ITTF の跳ねの規格 / 真値を持った世界 / ラリーの本数 |
| 2 | けん玉 —— 糸は引くだけ、玉は放物線、皿は画像の予測で運ぶ | 楕円積分と周期の定理 / 張力と弛む角の閉形式 / 日本けん玉協会の寸法 / 真値を持った世界 / 3DGS の投影と合成の式 |
| 3 | 回転で曲がる球を撮って、回転を 2 通りで読む —— 曲がり方から / 模様から | ω × v の定理(進行方向の回転は力を生まない)/ 真値を持った世界 / 独立な 2 つの読み(軌跡と模様)の一致 |
| 4 | 跳ねる球を高速カメラで撮って、反発係数と摩擦係数を読む —— 公表値と照合する | Cross 2002 の跳ねの閉形式 / ITTF の台の跳ね / Inaba ら 2017 の反発係数の速さ依存 / 真値を持った世界 |
| 5 | 読みの誤差がラリーを終わらせる —— 雑音と遅れが着地点をどれだけ動かすかを、打つ前に閉形式で出す | 真空の誤差伝播の閉形式 / 一次の誤差伝播 J Σ Jᵀ / 二項分布 / 等加速度の遅れ |
力学 : 抗力(Re 依存の球の経験式)+ マグヌス(スピン比)+ スピン減衰 + 速度依存の反発係数 + クーロン摩擦の跳ね(掴む / 滑る)を RK4 で
世界 : ITTF の台(2.74 × 1.525 m、高さ 0.76 m、ネット 15.25 cm)と 40 mm の球(黒い模様 14 個)を真値つきで描く。追跡カメラ 2 台 + 跳ね際の近接カメラ 1 台
映像 : 色度で球を検出(サブピクセル)→ 等速予測で追跡 → DLT で三角測量 → 等加速度 Kalman
予測 : z の局所最小で跳ねを検出 → 跳ねる前の 15 コマから運動方程式の 6 パラメータを Gauss–Newton で当て、跳ねを越えて着地点を読む
測る : 前後 12 コマの当てはめから反発係数 e、近接カメラの模様の対応(Kabsch)から角速度
ラリー : 2 本のラケットが相手コートで 1 度跳ねた球を面で迎え、狙った点へ返す。打つ前に先読みして外すなら巻き戻す。本数が指標
かみ砕き: 球の動きを決める式は 3 つです。抗力(速いほど強く減速する)、マグヌス力(回転と進行方向の外積の向きに曲がる)、跳ね(台に当たると鉛直の速さが e 倍で返り、水平は摩擦で削られ回転が変わる)。追跡はカメラの像で球を見つけて 3 次元に戻す作業、予測は式を積分する作業、測定はその逆問題です。
↑ 追跡カメラ 1 のコマ(t = 0.15 s)。球は像で半径 4 px しかない。十字は世界の側が持っている真値の投影。
| # | 門 | 真値 | 成績 |
|---|---|---|---|
| 1 | 真空の恒等式 | 閉形式 | RK4 と閉形式の差 1e-12、放物線の当てはめで g = 9.81 が 4e-15 で戻る |
| 2 | 跳ねの定理 | 接触点まわりの角運動量保存 | 乱数 500 通り(e ∈ [0.3, 1]、μ ∈ [0, 0.6])で相対誤差 最大 4.9e-16、エネルギーは増えない、転がりに移る 136 / 滑ったまま 364 |
| 3 | 頂点の等比と ITTF の跳ね | e^{2k}h₀ と規格 24〜26 cm | 30.5 cm から落として頂点 24.7 / 20.0 / 16.2 / 13.1 cm、閉形式との差 3e-7。総時間 4.736 s(閉形式 4.738 s) |
| 4 | 検出 | 世界の真値の投影 | 122 / 122 コマ、中心の誤差 中央値 0.15 px(像の半径 4.3 px) |
| 5 | 三角測量 | 世界の真値 | 真値の投影から 4e-15 m、検出から 中央値 1.6 mm |
| 6 | Kalman | 放物線の真値 | 新息 最大 8e-9 m(抗力とマグヌス入りの真値だと 2e-3 m = モデルの外)、速度の誤差 中央値 0.07 m/s |
| 7 | 跳ねの検出 | 世界の接触時刻 | 差 0.6 ms(1 コマ = 10 ms) |
| 8 | 跳ねを越える予測 | 世界の着地点 | 真のスピンを知っていれば 0.4 cm、スピンを無視すると 20.2 cm、放物線だと 6.6 cm |
| 9 | スピン | 世界の角速度 | 模様が対応づいたコマ組 19 / 19、|ω| 2394 rpm の相対誤差 3.7 % |
| 10 | 反発係数 | 世界の e = 0.90 | 運動方程式の当てはめで 0.911。放物線の当てはめだと 0.940(抗力とマグヌスを g に吸う) |
| 11 | ラリーの本数 | 上限・攻めの有無・知覚の雑音 | 送り合い 12 本(上限)、攻め vs 送り 9 本(out)、知覚の雑音 0 / 50 / 100 mm → 8 / 8 / 2 本 |
↑ 検出から三角測量した点(誤差の中央値 1.6 mm)は真値に乗る。跳ねる前の 15 コマから当てた初期状態で先を読むと、2291 rpm のトップスピンを知っていれば着地点は 0.4 cm、スピンを無視すると 20.2 cm 外れる。
門 11 の読み方(この記事の主張)。ラリーの本数は、検出・三角測量・予測・打ち方・ラケットの移動の全部が同時に合っているときだけ伸びます。どこか 1 段が壊れると本数に出る。だから 1 つの数で採点できます。片方を「勝つ打ち方」(遠い隅を速く)にすると 9 本で終わり、両方を「前回に近い、少しずらした位置へ返す」にすると上限まで続く。知覚に 5 cm の雑音を足しても本数は減らず、10 cm で 2 本に落ちる。減らない区間は板(15 × 16 cm)の余裕です —— 板の半分より小さい誤差は本数に出ない。これは指標の分解能でもあります。
巻き戻し。打つ前に真の物理で先読みして、ネット・自陣で跳ねる・届かない高さ、のどれかなら時間を戻してやり直す(回数を数える)。攻める側で 99 回巻き戻しても 9 本で終わるのは、計画器の式(閉形式の狙い + 反復)と世界の物理(Re 依存の抗力・スピン比の揚力)のずれが、速い球ほど効くからです。巻き戻しの回数は計画器と物理のずれの量として読めます。
等加速度 Kalman は放物線には厳密です(新息 1e-9 m)。抗力とマグヌスが入った軌跡を入れると新息が 2e-3 m 出ますが、それはモデルの外にいるだけで、フィルタの欠陥ではありません。門は放物線で立て、実物の軌跡には「予測は運動方程式で」と役割を分けます。
跳ねの前後を放物線で当てて v_z を出すと、抗力とマグヌスが重力に吸われて e = 0.940(真値 0.90)。運動方程式で当てると 0.911。着地点も放物線だと 6.6 cm、運動方程式だと 0.4 cm。当てる式は予測する式と同じにする。
1 つの模様の向きが分かっても、その軸まわりの回転は見えません。2 つ以上の模様が同じコマ組で見えて初めて Kabsch(2 組の向きから回転を最小二乗で出す)が立ちます。6 方向だけの模様だと見えるのは 2〜3 個で、暗い縁と混ざって 1 個に落ちるコマが出る。14 個(6 軸 + 8 隅)にして、近接カメラはストロボ照明(陰影を弱く)にすると 19 / 19 のコマ組で対応がつきます。
| ラケットは面が動くので、球の速度をそのまま跳ねの式に入れると間違えます。面の速度を引いて跳ねさせ、また足す。狙いの逆算(閉形式)も同じで、n ∝ v_out − v_in、v_r·n = | Δv | / (1 + e) + v_in·n。 |
bang-bang(全力で加速し、間に合うところで全力で減速)を離散時間で回すと、止まる直前の 1 歩で加速度の上限を超えます。止まる速さを max(0, √(2ad) − a·dt) にして、離散の 1 歩ぶんの余裕を取る。
頂点は e^{2k}h₀ で、跳ねの間隔は等比で縮み、総時間 √(2h₀/g)(1 + e)/(1 − e) の中に無限回の接触が入ります。接触時の鉛直の速さが閾値を切ったら台に置く、と決めないと積分は止まりません。
正規化の分母が零のとき、例外を出さずに NaN を返すと、ラリーの残り全部が NaN のまま「続いた」ことになります。fail-closed(ValueError)にして、門は NaN を通しません。
本物の映像はまだ入れていません。 カメラ・台・球は真値を持った合成世界です。検出器(色度)は照明の陰影には強いですが、背景に同じ色度の物があるとそちらを拾います。実写は次回以降の題材です。
抗力係数と揚力係数の既定値は文献値です。 測定値ではありません。同定の op(fit_aero: 軌跡から C_d・C_L を Gauss–Newton で当てる、真値で 1e-15 で戻る)はありますが、本物の球で回してはいません。
ラリーの計画器は、世界の物理を知っています。 巻き戻しは「真の物理で先読み」なので、実機ではそのままは使えません。実機では予測器(運動方程式の当てはめ)を先読みに使うことになり、巻き戻しは「予測器の言うことと実際のずれ」に化けます。
回転の測定は模様が要ります。 無地の球では Kabsch は立ちません。継ぎ目やロゴを使う方法は別の op です。
git clone https://github.com/furuse-kazufumi/fullseye
cd fullseye
py -3.11 examples/poc_ball_bounce.py # 門 11 本と図 8 枚(GIF 2)、約 110 秒
import numpy as np
import fullseye as fs
bp = fs.ledger.ball_params() # 40 mm・2.7 g、抗力 0.4、揚力はスピン比から
ip = fs.ledger.impact_params(0.90, 0.25) # 反発係数 e、摩擦係数 μ
tp = fs.ledger.table_params() # ITTF: 2.74 × 1.525 m、高さ 0.76 m
# トップスピンの打球を台の上で飛ばす(台は z = 0.76、範囲は台の面)
r = fs.ledger.flight_simulate([-1.3, 0.0, 1.0], [5.2, -0.3, 0.6], [0.0, 240.0, 0.0], bp, ip,
t_end=0.8, table_z=tp["height"], table_xy=(-1.37, 1.37, -0.7625, 0.7625))
c = r["contacts"][0]
print(round(c["t"], 4), np.round(c["p"][:2], 3), c["regime"], np.round(c["omega_out"], 1))
# 0.2495 [-0.095 -0.07 ] grip [ 7.8 228.8 0. ] ← 接触時刻 [s]、点 [m]、掴んだか滑ったか、跳ねた後の角速度 [rad/s]
# 30.5 cm から落とす: 頂点の等比と ITTF の規格(最初の跳ね 24〜26 cm)
h = fs.ledger.apex_sequence(0.305, 0.90, 4) # h₀ と、その後の頂点 4 つ
print(np.round(100 * h, 1))
# [30.5 24.7 20. 16.2 13.1]
# 2 本のラケットで 12 本まで打ち合う(送り合い)
rp = fs.ledger.racket_params()
from racket import strategy_feeder, strategy_attacker
res = fs.ledger.rally_simulate(bp, rp, tp, strategies=(strategy_feeder, strategy_feeder), retries=0, max_hits=12, seed=1, table_ip=ip)
print(res["hits"], res["end_reason"], res["rewinds"])
# 12 max_hits 0
end_reason が out / net / double_bounce / unreachable のどれで終わったかを見てください。攻める側にすると 9 本で out になります。本数が減ったら、それは球の運動が変わったか、知覚か、計画器か —— 巻き戻しの回数と合わせて読むと、どの段かが絞れます。
この回が作った図は全部で 8 枚あります —— 全部見る

↑ 追跡カメラ 1、100 fps を 1/10 速で。橙の十字は検出、緑は Kalman の状態の投影、赤は跳ねる前の 15 コマから予測した着地点。
↑ 30.5 cm から落とした球(e = 0.90)の頂点。閉形式 e^{2k}h₀ と 1e-6 で一致し、最初の跳ね 24.7 cm は ITTF の規格 24〜26 cm の中。
↑ 跳ね際の近接カメラ(1000 fps、20°)の 4 コマ。黒い模様(14 個のうち 4〜6 個が見える)の向きを前のコマと対応づけ、Kabsch で回転を当てる。
↑ カメラ 1 の像での球の軌跡。真値の投影(線)と検出(点)。中心の誤差の中央値 0.15 px。
↑ 知覚(球の位置)にガウス雑音を足したときのラリーの本数(送り合い、上限 8 本)。5 cm までは減らず、10 cm で 2 本。減らない区間は板の余裕。

↑ 右 = 閉ループの知覚が実際に見た画像(世界を 3D Gaussian Splatting にして描いたもの)、左 = 同じ瞬間の真の形。右上の窓はけん玉のまわりを 3 倍の解像度で描き直したもの。青の輪 = 色度で検出した玉、十字 = 糸が弛んだ後のコマに当てた放物線から読んだ着地点。皿を動かす根拠は右の画像だけです。
日本けん玉協会の級の技のうち、玉を真上に引き上げて皿で受ける 4 つを扱います。
| 技 | 持ち方 | 受ける皿 |
|---|---|---|
| 大皿 | 皿持ち(けん先は斜め下、大皿が上) | 大皿 |
| 小皿 | 同じ持ち方 | 小皿 |
| 中皿 | けん持ち(けん先をほぼ真下) | 中皿 |
| ろうそく | けん先をつまむ | 中皿 |
手本の手順は共通です: 膝で真上に引き上げる → 糸が弛んでからけんを動かす → 落ちてくる所の真下へ皿を水平に運ぶ(すくいに行かない)→ 着地で膝を曲げて衝撃を吸う。この PoC はこの手順をそのまま制御の段階にしました。
約束は 4 つです。(1) 玉を動かすのは重力と糸の張力だけ(張力 ≥ 0 の片側拘束)。(2) けんと皿胴は 1 つの剛体で、位置は自由に動かしてよい。(3) 玉は糸が弛んだ後に昇り、頂点を越えて下りてくるところを受ける(真上の軌道も放物線と見なす)。(4) けん先は玉の穴にだけ使う —— 皿の技でけん先や皿胴に玉が触れたら失敗です。
形 : 日本けん玉協会の公表値(玉 60 mm・横幅 70 mm・全長 180 mm)と JKA 16-2 型の説明(けん 160 mm、皿 42 / 38 / 35 mm)で、
けんと皿胴を回転体に。玉は穴(直径 17 mm・深さ 40 mm のくぼみ)ごと回転体
力学 : 糸 = 張力 ≥ 0 の片側拘束(弛めば自由落下、張った瞬間に径方向の速さを失う)を射影法で。空気抵抗つき
世界 : 2 台のカメラ(480 × 360、100 fps)。真値を持った合成世界。さらに 3D Gaussian Splatting にしてから描く
知覚 : 色度で玉を検出 → 2 台で三角測量 → 玉と糸穴の距離が糸より短いコマが 2 回続いたら「弛んだ」→ 重力つきの放物線(未知 6)を当てる
制御 : lift(膝で真上に)→ wait(弛むまで動かさない)→ hold(玉がけんを越えるまで寄せない)→ carry(皿を着地点の真下へ水平に)
→ absorb(着地で下げる)
採点 : 縁に触れた瞬間に 横ずれ ≤ 縁の半径・相対速さ ≤ 1 m/s・下降中。けんに触れたら hit_ken(失敗)
かみ砕き: 糸は押せません。引くことしかできない「片側の拘束」です。だから玉を真上に強く引き上げると、ある瞬間に糸が弛み、そこから玉は放物線で飛びます。放物線は重力だけで決まるので、弛んだ後の数コマの位置が分かれば、どこに落ちてくるかが計算できます。皿はそこへ先回りして待てばよい。人が「玉をよく見て、落ちてくる所で待つ」とやっていることを、画像と放物線でやります。
↑ 4 技の持ち方の姿勢。けんと皿胴は 1 つの剛体で、持つ所だけが違う。寸法は頂点から測って公表値と 1e-9 で一致する。
| # | 門 | 真値 | 成績 |
|---|---|---|---|
| 1 | 楕円積分と大振幅振り子の周期 | 公表値 K(0.5) と定理 T = 4√(L/g)K(sin θ₀/2) | K の誤差 0、周期はひも 3.0e-4・棒 8.9e-11 |
| 2 | 張力と弛む角 | 閉形式 m(v²/L + g cos θ)、cos θ_s = (2/3) cos θ₀ | 張力 0.26 %、弛む角 125.04°(閉形式 125.26°) |
| 3 | エネルギーと snap | 飛翔中は保存、張った瞬間の損失 = ½ m v_r² | 飛翔中の幅 2.5e-15 J、snap 0.2362 J vs 0.2367 J、散逸は dt に 1 次(比 9.7) |
| 4 | 形 | 公表値(横幅 70・けん 160・皿 42/38/35・玉 60・全長 180 mm) | すべて 1e-9 |
| 5 | 真値の知覚で大皿 | 段階の順・けんに触れない・弛んだ後に昇って下降中に受ける | 横ずれ 4.22 mm で捕る。手元を逃がさないと 0.260 s に皿胴に当たる |
| 6 | 検出 → 三角測量 → 弛み | 世界の真値 | 誤差 中央値 0.51 mm、弛みの検出は真値より +23 ms |
| 7 | 画像だけの閉ループ | 真値の知覚との成功率 | 20 試行で真値 1.00 / 画像 1.00(計画に渡った推定 390 回は全部画像から) |
| 8 | 同じ計画で 3 技 + ろうそく | 飛び方とけんとの接触 | 小皿 1.00、中皿 0.95、ろうそく(真値)0.95。着地で下げると相対速さ 0.91 → 0.61 m/s |
| 9 | 予測誤差 | 捕球の瞬間の玉の位置 | 弛んだ後 3 → 44 コマで 10.51 → 0.98 mm |
| 10 | 画素雑音 | 成功率 | 0〜2 px で 1.00、8 px 0.90、16 px 0.40 |
| 11 | 穴の向き(静止した玉) | 真の穴の軸 | 40 姿勢中 14 で 2 台に見え、誤差 中央値 1.8° |
| 12 | 推奨品(大皿 49 mm) | JKA 型の成功率 | 1.00 vs 1.00 |
| 13 | 3DGS の世界で閉ループ | 真値の知覚 | 間隔 4 mm の 3DGS の画像だけで 1 + 2 試行すべて捕る、三角測量の誤差 中央値 0.30 mm |
| 14 | 3DGS のつまみ | 検出率 | 間隔 2〜64 mm で 1.00、位置の誤差 20 mm で 0.25、色の誤差 0.3 で 0.00 |
| 15 | 3DGS の上の穴 | 真の穴の軸 | 480 × 360 では 40 姿勢中 1、解像度 × 2・間隔 2 mm で 16(中央値 2.3°)、間隔 4 mm では 0 |
| 16 | 連続技 10 回(もしかめ・3 皿) | 放つ頂点 v²/2g、けん玉に触れない、昇ってから下降中 | 画像だけで両方 10 回連続(5 級相当)、相対速さ 0.41〜0.48 m/s |
↑ 玉は糸が弛んでから昇り、皿は玉がけんを越えるまで待ってから水平に戻り、着地の直前に下がって受ける。点は 2 台の三角測量。
門 9 の読み方。弛んだ直後の 3 コマで当てた放物線は、捕球の瞬間の位置を 1 cm 外します。短い区間の速度に三角測量の誤差が乗るからです。コマが増えるほど誤差は減り、44 コマで 1 mm を切る。皿は毎コマ新しい予測へ運び直すので、最初の予測が外れていても間に合います —— 大皿の縁の半径は 21 mm、それが「待てる」余裕です。門 10 で画素雑音 2 px まで成功率が落ちないのも同じ余裕で、落ちないあいだも横ずれは 2.4 → 4.1 mm と増えています。
合成世界のメッシュをそのまま描くと、画像は「きれいすぎ」ます。実際のシーンを 3D Gaussian Splatting(3DGS)で再構成すると、形はぼけ、位置にも色にも誤差が乗ります。そこで、世界を 3DGS にしてから描き、同じ画像処理にかける層を足しました(gsplatnp、numpy だけ)。
ガウシアン : 世界の面に貼る(面の番号 + 重心座標 + 面の局所座標での誤差を固定で持つ)。頂点が動けば付いて動く
大きさ : σ = 間隔(3DGS の初期値と同じ)。曲面からはみ出さない σ ≤ 0.5 R、角(皿の縁・穴の口)に接する面は 1/4
描画 : EWA 投影 Σ' = J W Σ Wᵀ Jᵀ + 0.3 I、手前から α 合成、Mip-Splatting の不透明度の補正
つまみ : 間隔(密度)・位置の誤差・色の誤差
↑ 同じ近接カメラで、メッシュと 3DGS(間隔 2 mm・16 mm・誤差つき)。粗いと滲み、誤差を載せると毛羽立って色がにじむ。
描画の門は 3 つの式です: 1 個のガウシアンの α 像の 2 次モーメントが Σ’ と一致する(別の投影で 3 次元の標本を写した共分散とも)、2 個重なった画素の色が手前からの合成の式どおり(並び順を変えても同じ像)、世界を剛体で動かすとガウシアンが同じ変換で動く。
結果は表の 13〜15 です。玉を追う分には 3DGS でもほとんど困りません —— 間隔を 64 mm まで粗くしても玉の検出率は 1.00 のまま(曲率の上限で玉の上には 146 個以上が残る)。崩すのは位置の誤差と色の誤差です。色度で検出する以上、色の誤差が検出器の許容(0.12)を越えれば見えなくなる。穴は違います。
| 穴を読めた姿勢(40 中) | メッシュ | 3DGS 間隔 2 mm | 3DGS 間隔 4 mm |
|---|---|---|---|
| 480 × 360 | 14 | 1 | — |
| 解像度 × 2 | 23 | 16(誤差 中央値 2.3°) | 0 |
480 × 360 だと玉は 17 px、穴は 5 px です。3DGS のぼけ(1 px ほど)が両側から入って、穴が塗りつぶされる。穴を読むには玉に画素が要り、穴の中にガウシアンが要る。とめけん(けん先を穴に入れる技)をやるなら、カメラをこの表の右下より良い側に置く必要があります。
受けたら、その状態から続けて別の皿で受けます。協会の級の技のもしかめ(大皿と中皿を交互に)と、大皿 → 小皿 → 中皿 の 3 皿の連続です。
放つ : 玉が乗った皿を上へ加速し、g より強く止める → 皿が玉より速く減速した瞬間に玉が離れる(頂点 = 離れた瞬間の v²/2g)
持ち替え : 飛んでいる間に けんと皿胴(1 つの剛体)を回して次の皿を上に(手首 ≤ 30 rad/s、仮定)
受ける : 次の皿を着地点の真下へ運び、落ちてくる玉と同じ向きに皿を動かして速さを合わせる(位置と速度を目標にする手元の制御)
知覚 : 皿からの「飛び始め」も画像から(皿の受け位置から 12 mm 離れたコマが 2 回続いたら)→ 放物線 → 次の皿

↑ もしかめ 10 回連続の玉の高さ(画像だけの閉ループ)。放物線で昇って下りてくるところを、持ち替えた次の皿で受ける。受けた後に少し沈むのは、皿を玉と同じ向きに動かして速さを合わせた分(膝のクッション)。
画像だけで もしかめも 3 皿も 10 回連続(協会のもしかめの級で 5 級相当)、着地の相対速さは 0.41〜0.48 m/s です。位置だけを目標にする手元の制御では、頂点を皿の 20 cm 上にすると 1 回も受けられません —— 皿が目標の位置で止まろうとするので、1.9 m/s で落ちてくる玉に速さを合わせられない。人の「膝で受ける」は、速度を合わせる制御です。
正直に書くと、雑音が無いと 100 回続いても同じ 1 周期の繰り返しで(放つ動きは閉形式で決まり、玉は真上に上がる)、回数は頑健さの証拠になりません。画素雑音 2 px を足すと もしかめ 3 回・3 皿 6 回で崩れます(着地の直前に当て直すたびに目標が揺れる)。
画像から三角測量して放物線を当てる部品を作っても、皿を動かす計画には世界の真値 (p, v) を渡したまま、ということが起きます。予測は「採点」にしか使われず、閉ループは画像を見ていない。門は「計画に渡った推定の回数 = 画像から出た推定の回数」で立てます(表の 7 と 13)。
静止から放した振り子の周期の門を「ひもで 120°」にすると落ちます。θ₀ > 90° では最初から張力が負で、真下から打ち出しても cos θ_s = (2/3) cos θ₀ の角で弛む。大振幅の周期は棒で測り、ひもには弛む角の門を立てます。
真上に引き上げると、玉はけん玉の真下から昇ってきます。そのままだと皿胴を突き抜ける(物理は衝突を解かないので、黙って通る)。玉とけん玉の隙間を毎 step 測って失敗に数え、振り上げの後半で手元を糸穴の側へ 10 cm 逃がします。逆側へ逃がすと、カメラからけんが玉を隠して予測が悪くなりました。
玉と糸穴の距離が糸より短い、を 1 コマで判定すると、画素雑音 2 px で張っている間に「弛んだ」と出ます。2 コマ続いたら、にします。
メッシュでは円盤でも穴に見えます。3DGS はガウシアンを中心の深さで並べて重ねるので、斜めから見ると手前の玉のガウシアンが 0.3 mm 浮いた円盤を覆い、40 姿勢中 0 回しか読めませんでした。本物どおり深さ 40 mm のくぼみ(暗い壁と底)にします。
抗エイリアスのために 2 次元の共分散へ 0.3 px² を足すと、画素より細いガウシアン(糸・皿の縁)が不透明のまま 0.55 px 以上に太ります(糸が 3 画素の太さで写った)。足したぶんだけ不透明度を √(det Σ / det(Σ + 0.3 I)) 倍にする(Mip-Splatting)と、α の総和が保たれます(門にした)。
本物の映像ではありません。 真値を持った合成世界です。3DGS も写真から学習したものではなく、真の形から作って誤差のつまみで崩したものです。「3DGS の表現と描画の上で画像処理が動くか」を測る道具で、再構成の質そのものは主張しません。
玉の回転は解いていません。 張っている間は糸穴が結び目を向き、弛んだら最後の姿勢のまま、という描画の約束です。飛んでいる間の穴の向きは真値ではなく、とめけんはまだ入れていません。
捕球は幾何の判定です。 縁での跳ねと転がりは扱わず、相対速さ ≤ 1 m/s は仮定の閾値です。皿持ちの傾き 15°、皿の深さ、玉 75 g も仮定。JKA 16-2 型の寸法はユーザーが提供した説明で、一次資料は未確認です。
ろうそくが中皿より難しい理由は表せません。 けんを剛体・並進だけで動かすので、つまんだ指の支えの弱さが入らず、数字は中皿と同じ相対運動になります。
py -3.11 examples/poc_kendama.py # 門 16 本(図なしで約 171 秒)
import numpy as np
import fullseye as fs
kp = fs.ledger.kendama_params(trick="ozara") # JKA 16-2 型、大皿(皿持ち)
print(round(np.degrees(fs.ledger.tether_slack_angle(np.radians(150.0), kp["pendulum_length"])), 2))
# 125.26 ← 真下から 150° 相当で打ち出すと、この角で糸が弛む
w = fs.ledger.kendama_world(kp) # 真値を持った世界(けん・皿胴・玉・糸)
gs = fs.ledger.gs_from_world(w, spacing=0.004) # 世界を 3DGS に(間隔 4 mm)
rig = fs.ledger.kendama_rig(kp) # 2 台のカメラ
c = rig[0]
img = fs.ledger.gs_render(gs, c["pose"], c["K"], c["width"], c["height"])["color"]
print(img.shape)
# (360, 480, 3)
kendama_params の trick を "kozara" / "chuzara" / "rousoku" にすると、同じ計画のまま持ち方だけが変わります。camera_perceiver(world, rig, render_fn=fs.ledger.gs_render_fn(gs)) にすると、閉ループの知覚が 3DGS の画像を見ます。
この回が作った図は全部で 11 枚あります —— 全部見る

↑ メッシュの世界の画像だけの閉ループ(カメラ 2、100 fps を 1/10 速)。十字は予測した着地点。

↑ 弛んだ後の n コマで当てた放物線から読んだ、捕球の瞬間の位置の誤差(縦軸は log10 mm)。

↑ 3DGS のつまみと玉の検出率。間隔は効かず、位置と色の誤差が効く。
↑ 積分器の張力と閉形式 m(v²/L + g cos θ)。0 になった角で糸が弛む。
↑ 画素雑音と成功率(20 試行ずつ)。2 px まで平らなのは皿の縁の余裕。
↑ カメラ 2 のコマ(玉が頂点)。左の枠は同じカメラで 3 倍の解像度に描き直した窓。

↑ 同じ速さ・同じ向き(v₀ = (6.0, 0, 1.3) m/s)で打ち出した 3 本を横から。トップスピン(橙)は沈んで手前に、無回転(黄)は真ん中に、バックスピン(青)は浮いて奥に落ちます。違うのは回転(150 rad/s ≈ 1,430 rpm)だけ。球は見やすさのため 1.6 倍で描いています。240 fps の全コマの動画(1/8 スロー)は MP4。
回転している球には、進む向きと回転軸の両方に垂直な力(マグヌス力、ω × v の向き)がかかります。前回転なら下向き、下回転なら上向き、横回転なら横向きです。だから軌跡の曲がり方には回転が書いてある。一方で球の表面の模様は、回転そのものを直接見せます。この回は同じ打球の回転を、この 2 つの独立な手がかりで読み、互いに照合します。
fit_spin)。spin_from_markers)。
↑ 上から。横回転(紫)は無回転(黄)と同じ打ち出しから 15 cm 横に逸れて落ちます。MP4

↑ 近接カメラ(1000 fps)のトップスピン、20 ms を 1/100 スローで。見えている 4〜6 個の模様を前のコマと向きで対応づけ、回転を当てます。MP4
| 門 | 真値の出どころ | 成績 |
|---|---|---|
| 恒等式 | 真値の軌跡を fit_spin に入れる |
回転が 1.3 × 10⁻⁴ rad/s で戻る |
| 読めない成分 | 定理: ω × v なので、進行方向に平行な回転は力を生まない | 瞬間の加速度の差 4.8 × 10⁻¹⁷ m/s²。0.25 s 飛んでも 7.1 mm(垂直な回転なら 5.4 cm) |
| 着地点 | 真値を持った世界 | 読んだ回転で先読みした着地点が 4 本とも 2 cm 以内(x = 0.488 / 0.754 / 1.124 m、順はトップ < 無回転 < バック) |
| 曲がり方から回転 | 同上 | 垂直成分の誤差 トップ 3.9 %・バック 0.9 %・横 1.7 %、無回転は 7.6 rad/s |
| 模様から回転 | 同上 | トップ 4.8 %・バック 3.2 %・横 0.9 %、無回転は 2.0 rad/s |
| 2 通りの一致 | 第 2 実装(片方は軌跡だけ、片方は模様だけを見る) | 3.0 %・3.1 %・1.9 % |
| 長さの効き | 同上 | 使う軌跡が 42 ms だと 400 %、321 ms で 4 % |

↑ 2 通りの読みの比較。曲がり方からは進行方向に平行な成分が読めないので、垂直成分で比べています。

↑ 曲がりが見えるまで、回転は読めない。先頭 n コマだけで読むと、短いうちは曲がり(マグヌスの分)が三角測量の誤差 1〜2 mm に埋もれます。
fit_spin は読める成分を omega_perp として別に返します。模様からは全成分が読めるので、ここが 2 つの手がかりの役割分担になります。| 回転の大きさが決まらないことがある。 揚力係数の模型 C_L = 1/(2 + 1/S)(S = rω/v)は回転が大きいと 0.5 で頭打ちになります。軌跡が短く雑音が勝つと、当てはめは「もっと強い力を」と | ω | を際限なく大きくします(減衰の無い Gauss–Newton だと 1 歩で 10⁷ rad/s に飛びます)。fit_spin は減衰つきで当て、上限(既定 1,000 rad/s)に張り付いたら at_bound = True を返します —— そのときは大きさを信じないでください。 |
marker_direction(..., K) とカメラの K を渡すと、模様の画素の視線を球面と交差させる透視の厳密な式で解きます。import numpy as np
import fullseye as fs
bp = fs.ledger.ball_params() # 40 mm・2.7 g、抗力 0.4、揚力はスピン比から
t = np.arange(0.0, 0.30, 1 / 240) # 240 fps で 0.3 s
f = fs.ledger.flight_ode([-1.3, 0.0, 1.01], [6.0, 0.0, 1.3], [0.0, 150.0, 0.0], bp, 0.31, 1e-4)
p = np.column_stack([np.interp(t, f["t"], f["p"][:, k]) for k in range(3)])
p_obs = p + np.random.default_rng(0).normal(0.0, 1.5e-3, p.shape) # 三角測量の誤差 1.5 mm 相当
r = fs.ledger.fit_spin(t, p_obs, bp)
print(np.round(r["omega"], 1), np.round(r["omega_perp"], 1), r["at_bound"])
# [ 16.5 157.8 -5.1] [ 1.5 157.7 -8.4] False ← 回転 [rad/s]、読める成分、上限に張り付いたか
r10 = fs.ledger.fit_spin(t[:15], p_obs[:15], bp) # 先頭 62 ms だけ
print(np.round(r10["omega_perp"], 1), r10["at_bound"])
# [ -1.8 -93.8 8.1] True ← 短いと読めない(上限に張り付く)
PoC 全体は py -3.11 examples/poc_table_tennis_spin.py(図と動画は FULLSEYE_FIGURE_DIR を設定したとき)。

↑ ITTF のルール(Laws 2.1.3)の台の跳ねの試験: 球を 30 cm から落とすと約 23 cm 跳ねること。動画から読んだ跳ねの高さは 23.0 cm。物差しは 1 cm 刻みです。240 fps の全コマ(1/8 スロー)は MP4。
卓球の球が台で跳ねるとき、縦の速さは反発係数 e の分だけ残ります。横の速さと回転は摩擦で入れ替わります。接地点(台に触れている点)の滑りが小さければ跳ねの途中で滑りが止まり、球は転がりに移って離れます。滑りが大きければ滑ったまま離れます。どちらになるかで、跳ねた後の球の行き先がまるで変わります。
この回は台の上の跳ね 22 本を 1 台の高速カメラ(1000 fps、横から)で撮り、画像だけから e と摩擦係数 μ と跳ねの種類を読み、公表値と照合します。
ray_plane_range)。flight_fit)。回転は球の模様から読む(spin_from_marker_sequence)。| e = −v_z’/v_z。接地点の滑り s = v_x − rω を跳ねの前後で比べ、滑ったままの跳ねで μ = Δv_x /((1+e) | v_z | )(Inaba らの式)。 |

↑ バックスピン(−150 rad/s)の跳ねを 1000 fps で(1/40 スロー)。接地点が大きく滑ったまま離れ、摩擦が回転をほぼ打ち消します(模様から読んだ回転 −150 → 0 rad/s)。MP4

↑ トップスピン(+200 rad/s)の跳ね。接地点の滑りが小さいので跳ねの途中で止まり、転がりに移って離れます(跳ねた後の rω’ = 3.320 m/s と v_x’ = 3.312 m/s がほぼ等しい)。MP4
| 門 | 真値の出どころ | 成績 |
|---|---|---|
| 跳ねの閉形式 | Cross 2002(薄い殻の球 I = (2/3) m r²): 転がりに移る跳ねは v_x’ = 0.6 v_x + 0.4 rω、滑ったままは Δv_t = μ(1+e)|v_z|、境目は (2/5)|s| = μ(1+e)|v_z| | 力積で書いた実装と、乱数 300 通りで種類 300/300、速度の差 2.7 × 10⁻¹⁵ m/s |
| ITTF の台の跳ね | 公表値(Laws 2.1.3: 30 cm → 約 23 cm) | 動画から 23.0 cm |
| e の速さへの依存 | 公表値(Inaba ら 2017、プラスチック球: −0.0058 /(km/h)) | 22 本(5.0〜16.4 km/h)の傾き −0.00572、差 1.4 %。1 本ごとの e の誤差は中央値 0.0004 |
| 摩擦係数 | 真値を持った世界(0.25、Inaba らの実測の切片 0.2526 に合わせた) | はっきり滑った 3 本で 0.250 |
| 跳ねの種類 | 読んだ e・μ で引いた閉形式の境目 | 15 / 15 一致(境目の ±10 % の帯の 1 本は判定しない) |
| 転がり | 転がりに移った跳ねは rω’ = v_x’ | 10 本で差 0.1〜2.3 % |
| 零点 | 摩擦の無い台 | 横の速さの変化 0.002 m/s、回転の変化 0.2 % |

↑ 動画から読んだ e は、速く当たるほど下がる。傾きは Inaba ら 2017 と 1.4 % で一致します。上の直線は Inaba らの式そのもの(切片 1.0002)で、ITTF の台より高く跳ねる側にずれています。

↑ 境目より下の跳ねは接地点が止まって転がりに移り、上の跳ねは滑ったまま離れる。境目は動画から読んだ μ と e で引いています。
import numpy as np
import fullseye as fs
bp = fs.ledger.ball_params()
ip = fs.ledger.impact_params(0.90, 0.25) # e、μ
r = bp["radius"]
v_in, n = [3.0, 0.0, -3.0], [0.0, 0.0, 1.0]
# 滑ったままの跳ね(バックスピン)と、転がりに移る跳ね(トップスピン)
for w in (-150.0, 200.0):
b = fs.ledger.bounce(v_in, [0.0, w, 0.0], n, bp, ip)
print(b["regime"], np.round(b["v"], 3), round(r * b["omega"][1], 3))
# slip [1.575 0. 2.7 ] -0.862 ← 滑ったまま: Δv_x = μ(1+e)|v_z| = 1.425
# grip [3.4 0. 2.7] 3.4 ← 転がりに移る: v_x' = 0.6·3 + 0.4·(0.02·200) = 3.4、rω' = v_x'
# 跳ねの前後の (v, ω) から e と μ を戻す(転がりに移った跳ねでは μ は下界)
b = fs.ledger.bounce(v_in, [0.0, -150.0, 0.0], n, bp, ip)
f = fs.ledger.fit_bounce(v_in, [0.0, -150.0, 0.0], b["v"], b["omega"], n, bp)
print(round(f["e"], 4), round(f["mu"], 4), f["regime"], f["mu_is_lower_bound"])
# 0.9 0.25 slip False ← e、μ、種類、μ が下界か
PoC 全体は py -3.11 examples/poc_table_tennis_bounce.py(図と動画は FULLSEYE_FIGURE_DIR を設定したとき)。

↑ 上から見た送り合い(1/2 速)。上 = 球の位置の読みに雑音なしで上限の 10 本、下 = 毎コマの読みに σ = 6 cm の雑音で 9 本、終わりはアウト。ラケットは球に届いています —— 途切れる理由は空振りではなく、読み違いから狙った打球のアウトです。MP4
第 1 回のラリーは「何本続いたか」だけを見ていました。この回は、なぜ途切れるのかを打つ前に数字で出します。
卓球ロボットは「球がどこにあるか」を読んでから、相手コートの狙った点へ落ちる打球を計算して打ちます。読みが δ だけずれていれば、打球の計算はずれた位置から狙ったものなのに、球は本当の位置から飛び出します。着地点がずれ、ずれが台の縁までの余白を越えるとアウトです。
鍵は「読みの誤差 → 着地点の誤差」の伝わり方(ヤコビアン J、2 × 3 の行列)です。これが分かれば、雑音のばらつきも、アウトの確率も、遅れの影響も、打つ前に出せます。
空気が無ければ、読みの誤差 δ = (δ_x, δ_y, δ_z) から着地点のずれは
| ΔL_xy = −δ_xy − (v_xy / | v_z(T) | ) δ_z |
です(v_z(T) = 台に落ちるときの縦の速さ)。前後左右の読み違いはそのまま逆向きに出ます。高さの読み違いは、落ちる角の分だけ前後に引き伸ばされます。 この打球では落ちる角が浅く、高さを 1 cm 読み違えると前後に 2.1 cm ずれます。

↑ 横から。高さを 5 cm 高く読むと、狙いの計算(灰)は低い弾道を選び、本当の位置から打った球(赤)は狙いより 10.2 cm 手前に落ちる。黄 = 正しく読んだ球。MP4
plan_return)・ラケットの計画・衝突・飛翔を通して、読みを 1 方向ずつ少し動かし、着地点の動きから数値の J を出す。
↑ 台の縁から 6 cm の点(白)を狙い、球の位置の読みに σ = 3 cm の雑音を入れて 300 本打つ。黄 = 入った、赤 = アウト(6 %)。水色の楕円は打つ前に J から出した 2σ で、前後に長い —— 高さの読み違いが前後に伸びるからです。MP4
| 門 | 真値の出どころ | 成績 |
|---|---|---|
| 真空の閉形式 | ΔL_xy = −δ_xy − (v_xy/|v_z(T)|) δ_z | 狙い・計画・衝突・飛翔を通した数値の J と差 2.5e-06 |
| 一次の誤差伝播 | J Σ Jᵀ(抗力 + マグヌスあり) | σ = 2 cm の読みで 300 本、着地点のばらつき (前後 4.6, 左右 2.0) cm が予測と 1.0・0.8 % |
| アウトの確率 | J Σ Jᵀ のガウス分布の、コートの外の確率 | 縁から 6 cm を σ = 3 cm で: 予測 0.043、打った 300 本で 0.060(二項の 1.5 σ) |
| 遅れ | J · (−vτ − ½gτ² ẑ) | τ = 5 / 10 / 20 ms で差 0.2〜0.6 % |
| 閉ループ | 雑音 0 は上限まで続く / 余白から出した雑音の上限 σ* を越えると途切れる | 雑音 0: 4 回とも 10 本。σ = 6 cm(σ* = 5.2 cm の 1.2 倍): 9・8・2・7 本、すべてアウトで終わり |
| 零点 | 誤差 0 なら狙った点 | 0.52 mm |

↑ σ = 2 cm の読みで打った 300 本の着地点。雑音は 3 方向に同じ大きさでも、ずれは前後に 2.2 倍伸びます。
import numpy as np
import fullseye as fs
tp, rp = fs.ledger.table_params(), fs.ledger.racket_params()
bp = fs.ledger.ball_params(rho=0.0) # 空気なし(閉形式と比べるため)
ip = fs.ledger.impact_params(0.9, 0.25)
H = tp["height"]
p, v_in, w_in = np.array([-1.55, 0.1, H + 0.25]), np.array([-4.0, 0.2, -0.5]), np.array([0.0, -50.0, 0.0])
target, T = np.array([0.75, 0.3, H + 0.02]), 0.42
def landing(delta):
"""読み違えた位置 p + δ から狙いを計算し、本当の位置 p から打った球の着地点。"""
w = np.zeros(3)
for _ in range(3): # 打球後の回転を込みで狙いを合わせる
aim = fs.ledger.aim_velocity(p + delta, target, T, bp, w)
plan = fs.ledger.racket_plan(v_in, w_in, aim["v"], bp, rp)
w = plan["omega_out"]
b = fs.ledger.racket_impact(v_in, w_in, plan["normal"], plan["v_racket"], bp, rp)
s = fs.ledger.flight_simulate(p, b["v"], b["omega"], bp, ip, 1.2, 2e-4, table_z=H)
return s["contacts"][0]["p"][:2], b["v"]
L0, v = landing(np.zeros(3))
h = 1e-3
J = np.column_stack([(landing(h * e)[0] - landing(-h * e)[0]) / (2 * h) for e in np.eye(3)])
vz_T = v[2] - 9.81 * T
print(np.round(J, 3))
# [[-1. 0. -2.1 ]
# [-0. -1. -0.183]]
print(np.round([-v[0] / abs(vz_T), -v[1] / abs(vz_T)], 3)) # 閉形式の 3 列目: −v_xy / |v_z(T)|
# [-2.1 -0.183]
PoC 全体: py -3.11 examples/poc_table_tennis_rally_loop.py(FULLSEYE_FIGURE_DIR を設定すると図と動画も)。
とめけん。 けん先を玉の穴に入れる技です。第 2 回のけん玉は皿に乗せるところまででした。穴の向きと玉の回転を画像から読み、けん先が穴に入る条件(穴の縁と先端の隙間)を閉形式で出して、画像の予測で入れにいきます。