<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>Optic Yellow Blog</title>
        <link>https://optic-yellow.com/blog</link>
        <description>Optic Yellow Blog</description>
        <lastBuildDate>Wed, 22 Jul 2026 23:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <copyright>Copyright © 2026 Trevor McCormick.</copyright>
        <item>
            <title><![CDATA[Stick Figures Beat Smudges]]></title>
            <link>https://optic-yellow.com/blog/stick-figures-beat-smudges</link>
            <guid>https://optic-yellow.com/blog/stick-figures-beat-smudges</guid>
            <pubDate>Wed, 22 Jul 2026 23:00:00 GMT</pubDate>
            <description><![CDATA[The forehand-or-backhand call has been stuck for two weeks. The fix: draw a skeleton on every player and read the actual swing.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top: the machine's new view of the players. Yellow stick
figures — wrists, elbows, hips, feet — drawn by a free model on real
match frames, including the far player at ninety pixels tall. And one
frame kept for honesty: the first version confidently drawing its
skeleton on a ball kid.</em></p>
<p>Today the software stops seeing players as smudges and starts seeing
swings. The question: does that finally move the forehand-or-backhand
call that's been stuck for two weeks?</p>
<p><strong>Previously.</strong> The machine finds players by comparing each frame
against an empty-court photo (<a class="" href="https://optic-yellow.com/primer#background-subtraction">background
subtraction</a>) — which yields a moving
<em>smudge</em> per player, no arms, no legs. The forehand/backhand letter has
been read from which side of the smudge the ball arrives on, and that
idea has formally hit its ceiling: <a class="" href="https://optic-yellow.com/blog/the-draft-grades-itself">careful
experiments</a> showed no amount of
re-anchoring the smudge changes the calls. Meanwhile this same week the
<a class="" href="https://optic-yellow.com/blog/the-court-that-fits-itself">court learned to fit itself</a> and the
machine <a class="" href="https://optic-yellow.com/blog/the-machine-grows-ears">grew ears</a>. The players are the
last part still made of fog.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="pose-estimation-drawing-the-swing">Pose estimation: drawing the swing<a href="https://optic-yellow.com/blog/stick-figures-beat-smudges#pose-estimation-drawing-the-swing" class="hash-link" aria-label="Direct link to Pose estimation: drawing the swing" title="Direct link to Pose estimation: drawing the swing" translate="no">​</a></h2>
<p>The upgrade is a technology every fitness app now uses: <strong>pose
estimation</strong> — a model that marks seventeen body landmarks (wrists,
elbows, shoulders, hips, knees, ankles) on any person in an image,
turning a smudge into a stick figure. Instead of guessing a stroke from
where a blob bulges, you can read the actual tennis: where the racquet
wrist is, which side of the body it crossed to, how the shoulders line
up at contact. A free, openly-licensed model does this at better than
real-time speed on this laptop — no cloud, no cost.</p>
<p>The open question was never the near player, who fills a fifth of the
screen. It's the far player, who stands about ninety pixels tall —
thumbnail-sized. Research on this exact problem predicted: shoulders
and hips will hold up, wrists will get shaky. The audit stills in the
clip say the prediction was right on both counts — the far player's
skeleton is real and readable, and the model's own confidence in the
far wrists hovers around fifty-fifty, versus a solid two-thirds for
shoulders.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-head-to-head">The head-to-head<a href="https://optic-yellow.com/blog/stick-figures-beat-smudges#the-head-to-head" class="hash-link" aria-label="Direct link to The head-to-head" title="Direct link to The head-to-head" translate="no">​</a></h2>
<p>Two matches with human answer keys, the two where the letter call is
benchmarked: the Paris clay final and the Wimbledon final — grass being
the surface where the smudges smear worst. Every method graded on the
same shots against the same human charts:</p>
<table><thead><tr><th>Surface</th><th>Smudge rule (shipped)</th><th>Skeleton rule</th></tr></thead><tbody><tr><td>Clay</td><td>36 of 44 — 82%</td><td>42 of 50 — 84%</td></tr><tr><td>Grass</td><td>16 of 30 — 53%</td><td>23 of 35 — 66%</td></tr></tbody></table>
<p>On clay: a tie, two points better — exactly what the earlier
counterfactual experiments predicted, since on clean footage the smudge
and the skeleton mostly agree about where a body is. On grass, where
the boxes fall apart: <strong>thirteen points better</strong>. The skeletons help
most precisely where the old method fails hardest — the same pattern as
the ears last post, and it's not a coincidence: every new witness earns
its keep in the old witnesses' blind spots.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-blooper-kept">The blooper, kept<a href="https://optic-yellow.com/blog/stick-figures-beat-smudges#the-blooper-kept" class="hash-link" aria-label="Direct link to The blooper, kept" title="Direct link to The blooper, kept" translate="no">​</a></h2>
<p>The first version had a bug that one audit image exposed instantly. It
matched each skeleton to the player by picking the detected person
nearest the smudge's center — and on one clay point, a rogue smudge box
(an old known disease) sat so far from the actual player that the
nearest human was a <strong>ball kid at the back fence</strong>. The machine
carefully drew a skeleton on the ball kid and graded his forehand.</p>
<p>The fix is pure tennis sense: a candidate skeleton only counts if its
<em>feet are standing in the striker's half of the court</em> — the
<a class="" href="https://optic-yellow.com/primer#homography">screen-to-court conversion</a> makes that a
one-line check. Ball kids kneel at the fences and net posts; umpires
sit outside the lines; players play inside them. The fix costs
coverage — with strict standing-room rules and shaky far-player ankles,
the matcher now declines about half the shots rather than risk grading
the wrong human — and recovering that coverage by tracking skeletons
<em>across</em> frames, instead of judging each frame alone, is the obvious
next version. Fourth time this week that a rendered picture caught what
the accuracy numbers politely hid.</p>
<p><strong>The takeaway:</strong> given actual arms to read instead of a fog bank, the
machine's forehand-or-backhand call improved most exactly where it was
worst — and the day's best evidence was one frame of a very
well-postured ball kid.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><p>Module: <code>experiments/pose_letters.py</code> (report + audit stills in
<code>outputs/diag/</code>; run with
<code>PYTHONPATH=.:experiments uv run --with rtmlib --with onnxruntime python experiments/pose_letters.py</code>).</p><p>Stack: rtmlib (Apache-2.0) <code>Body</code> pipeline (person detector + RTMPose),
ONNX Runtime CPU, weights auto-downloaded; ~1 s/frame with detection on
this laptop. Posed only at charted contact frames (cheap gate test, not
a per-frame rollout).</p><p>Protocol: MCP truth letters on length-matched aligned clips (the eval
convention); all four players right-handed per match configs. Feature
A "wrist-cross": sign of (right-wrist x − shoulder-midpoint x),
mirrored for the far player (faces the camera). Feature B "ball-side,
pose anchor": ball x at contact vs shoulder midpoint — the re-anchoring
control. Results (same-shots blob baseline in parens): t3 — A 37/50
(74%), B 42/50 (84%) vs blob 36/44 (82%); t4 — A 21/35 (60%), B 23/35
(66%) vs blob 16/30 (53%). Wrist-cross underperforming ball-side is
expected at contact-frame-only sampling: two-handed backhands and
preparation frames confuse a single-frame wrist test; windowed
skeletons are roadmap #9's job (the keypoint-graph model, where
few-shot literature shows 2-3x gains).</p><p>Keypoint confidence (Lsho/Rsho/Lwri/Rwri means): near 0.70-0.74 /
0.50-0.57 wrists; far 0.61-0.65 / 0.45-0.57 wrists — matching the
"shoulders hold, wrists shake" prediction for ~90 px players at 720p.</p><p>v1→v1.1: nearest-blob matching graded a ball kid
(<code>outputs/diag/pose_blooper_ballkid.png</code>, kept); v1.1 requires
candidate ankles to project (via the homography) into the striker's
court half — far: court-y ∈ [−4, 11], near: [12.8, 28], x ∈ [−1.5,
12.5] — then takes highest mean confidence, blob box demoted to
tiebreak. Coverage cost: t3 graded shots 93 → 50 (no fallback when
ankles fail the test); temporal tracking recovers this in v2.</p><p>Hero renderer: <code>experiments/render_pose_hero.py</code>.</p></div></div></details>
<p>Session cost: $0.00 — openly licensed weights, laptop inference.
Project total: ~$16.</p>
<p>Next question: in one day the machine learned to call lines by physics,
hear impacts, fit its own court, and read swings — four new witnesses,
each stronger where the others are weak. None of them are wired into
the shipped pipeline yet; they're experiments with receipts. The next
build is the courtroom: the fusion decoder that hears every witness,
weighs each one's track record, and writes the chart no single witness
could — with the grammar of tennis notation making illegal testimony
impossible to write down.</p>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
        <item>
            <title><![CDATA[The Court That Fits Itself]]></title>
            <link>https://optic-yellow.com/blog/the-court-that-fits-itself</link>
            <guid>https://optic-yellow.com/blog/the-court-that-fits-itself</guid>
            <pubDate>Wed, 22 Jul 2026 21:00:00 GMT</pubDate>
            <description><![CDATA[A human has clicked four corners for every match this project ever charted. A free pretrained network just took the job — and knew when to refuse it.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top: eight tournament courts, two overlays each. Green is
the court fit a human built by clicking four corners. Orange is a free
pretrained network's fit — zero clicks. On seven courts they're a few
pixels apart. On the Roland Garros plate — the one that broke the old
automatic fitter — the orange lands first try. And on the night match,
the machine does something arguably better than fitting: it declines.</em></p>
<p>Today the software tries to take over the last job a human still does
for every single match: teaching it where the court is.</p>
<p><strong>Previously.</strong> Everything this machine calls — serves, bounces,
<a class="" href="https://optic-yellow.com/blog/nobody-sees-the-ball-land">wide-or-deep</a>, who's standing where —
runs through the <strong>homography</strong>, the screen-to-court-diagram conversion
(<a class="" href="https://optic-yellow.com/primer#homography">primer</a>) built from the court's painted lines.
Since <a class="" href="https://optic-yellow.com/blog/m1-pixels-to-meters">the very first fit</a>, getting that
conversion has ended one of two ways: an automatic fitter finds the
lines, or — on stubborn footage — a human clicks the four corners by
hand. The automatic fitter has a famous scar: at Roland Garros, faint
brushed-over baselines and reddish stands fooled it so badly its fit
missed the court by 74 pixels, and a human had to step in. Clicking
corners takes a minute. But a minute per match, times the thousands of
matches this project wants to chart, is the difference between a tool
and a hobby.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="borrowing-a-scout-whos-seen-9000-courts">Borrowing a scout who's seen 9,000 courts<a href="https://optic-yellow.com/blog/the-court-that-fits-itself#borrowing-a-scout-whos-seen-9000-courts" class="hash-link" aria-label="Direct link to Borrowing a scout who's seen 9,000 courts" title="Direct link to Borrowing a scout who's seen 9,000 courts" translate="no">​</a></h2>
<p>The fix wasn't invented here, and that's the point. Someone on the
internet already trained a small network to find tennis courts in
broadcast frames — not by hunting for four corners, but by recognizing
<strong>fourteen landmarks at once</strong>: every corner, every T, every place two
painted lines meet. It learned from 8,841 broadcast images across hard
courts, clay, and grass. It's free, it's small, and it runs on this
laptop in under a second per image.</p>
<p>Fourteen landmarks instead of four is the difference between asking a
tourist for the two street corners they remember and asking a letter
carrier who knows every house on the block. If a couple of landmarks
are hidden or faint, twelve others still pin the court down — and the
fitter can even check its own work, by fitting on some landmarks and
measuring how well it predicted the ones it didn't use.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-scoreboard">The scoreboard<a href="https://optic-yellow.com/blog/the-court-that-fits-itself#the-scoreboard" class="hash-link" aria-label="Direct link to The scoreboard" title="Direct link to The scoreboard" translate="no">​</a></h2>
<p>Each court's automatic fit, measured against the human's hand fit at
six landmarks (the four court corners and the two net posts):</p>
<table><thead><tr><th>Court</th><th>Machine vs human, average gap</th></tr></thead><tbody><tr><td>US Open (hard)</td><td>2 pixels</td></tr><tr><td>Melbourne (hard, night session plate)</td><td>3 pixels</td></tr><tr><td>Turin (indoor)</td><td>5 pixels</td></tr><tr><td>Montreal (hard)</td><td>7 pixels</td></tr><tr><td>Wimbledon (grass)</td><td>8 pixels</td></tr><tr><td><strong>Roland Garros — the 74-pixel disaster plate</strong></td><td><strong>8 pixels, first try</strong></td></tr><tr><td>Paris 2023 (clay)</td><td>16 pixels — with a twist, below</td></tr><tr><td>Nadal–Shapovalov (night, dim)</td><td><strong>no fit — the machine declined</strong></td></tr></tbody></table>
<p>Seven of eight courts, fit to within a few pixels of a careful human,
by a network that had never seen any of them. And the eighth result
might be the most important one on the board: on the dim night match,
the network found only 2 of its 14 landmarks — so instead of guessing
from starvation-level evidence, the fitter <strong>abstained</strong> and left the
job to a human. That's the behavior this whole project is organized
around: an automatic call when the evidence is strong, a raised hand
when it isn't — the same contract as the
<a class="" href="https://optic-yellow.com/primer#confidence">confidence flag</a> that grades every drafted point.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-twist-the-student-corrected-the-teacher">The twist: the student corrected the teacher<a href="https://optic-yellow.com/blog/the-court-that-fits-itself#the-twist-the-student-corrected-the-teacher" class="hash-link" aria-label="Direct link to The twist: the student corrected the teacher" title="Direct link to The twist: the student corrected the teacher" translate="no">​</a></h2>
<p>One court disagreed with the human by 16 pixels, and I went in expecting
to document the network's failure. The overlay says otherwise: on that
clay plate, the <em>orange</em> lines sit squarely on the painted court, and
the <em>green</em> — the human hand fit that all the benchmarks treat as
truth — floats visibly inside the paint on the far side. The
"error" may be the teacher's. It's not settled yet — the neutral
referee will be the pipeline's existing line-scorer, which measures any
fit directly against the paint itself — but it's a healthy reminder
this series keeps getting taught: hand-made truth is still hand-made.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-bug-kept-on-the-record">The bug, kept on the record<a href="https://optic-yellow.com/blog/the-court-that-fits-itself#the-bug-kept-on-the-record" class="hash-link" aria-label="Direct link to The bug, kept on the record" title="Direct link to The bug, kept on the record" translate="no">​</a></h2>
<p>First run, every single fit came back wrong by the <em>same</em> enormous
amount — about 750 pixels, all eight courts. A consistent error is a
convention error, not a model error: the borrowed code, it turns out,
already doubles its coordinates internally (it assumes 720p footage),
and my wrapper doubled them again. One rendered overlay exposed it
instantly; the numbers alone never would have named the cause. Third
time this week: <strong>eyes beat derivatives</strong>.</p>
<p><strong>The takeaway:</strong> the last per-match chore a human always did — teaching
the machine where the court is — just went automatic for seven courts
out of eight, and on the eighth the machine did the next best thing to
being right: it knew it didn't know.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><p>Module: <code>experiments/court_autofit.py</code> (report + per-match overlays in
<code>outputs/diag/</code>; run with
<code>PYTHONPATH=.:experiments uv run --with scipy python experiments/court_autofit.py</code>).</p><p>Detector: yastrebksv/TennisCourtDetector — TrackNet-style heatmap net,
640×360 input, 15 output channels (14 keypoints + center), pretrained
on 8,841 YouTube broadcast frames (hard/clay/grass), median error
1.83 px refined / 2.83 px raw per its README. <strong>No license file</strong> — fine
for this research phase; the blueprint's risk register already flags
retraining a self-labeled replacement before any commercial use (every
hand-fit match auto-generates 14-keypoint labels through its known
homography). Weights: 42 MB, CPU inference ~1 s/plate.</p><p>Method: run on each match's <code>plate_fit.png</code> (the stabilized median
plate the hand homography was fit on); solve via the author's
12-configuration search (reimplemented numpy-only): each candidate
4-point configuration fits a homography, is scored by mean prediction
error on the <em>unused</em> detected keypoints (cross-validation, not
self-consistency), best wins. Convert reference-canvas px → meters
(doubles corners (286,561)–(1379,2935) ↔ 10.97 × 23.77 m) and compose
into the repo's <code>H_img_to_court</code> convention.</p><p>Landmark deltas vs hand fits (mean/max px): t5 2.7/3.1, t6 2.4/4.0,
t7 4.7/8.6, t2 7.3/13.9, t4 8.0/15.2, g1 8.0/12.2, t3 15.7/29.0;
t1 abstained (2/14 kps; config search requires a complete 4-point
configuration). Config cross-validation errors 2.5–7.0 px on accepted
fits. Classical auto-fit's g1 failure: rms 74 px (LOG, cv-18 era).</p><p>The 2× bug: <code>postprocess(heatmap, scale=2)</code> upscales heatmap coords
internally; wrapper scaled again → uniform doubling, self-consistent
config-xval, ~750 px landmark deltas. Caught by the first overlay
render.</p><p>Next wiring (blueprint roadmap #2's remaining half): use this as the
<em>proposer</em> inside <code>fitcourt</code>, judged by the existing step-5 line-mask
scorer with a calibrated accept threshold (the TVCalib abstention
pattern), plus the 1 Hz drift watchdog. Night feeds: try per-clip
plates, gamma/CLAHE boost, or fine-tuning with the hand-fit frames the
project already owns.</p><p>Hero renderer: <code>experiments/render_court_hero.py</code> (montage of the
per-match overlays).</p></div></div></details>
<p>Session cost: $0.00 — pretrained weights, laptop inference. Project
total: ~$16.</p>
<p>Next question: the court now fits itself and the machine hears every
impact, but the players are still <em>smudges</em> — blobs from background
subtraction, which is why forehand-versus-backhand has been stuck at a
ceiling for two weeks. The research says the upgrade is stick figures:
wrists, elbows, hips, feet, drawn on every frame by a free model that
runs at 87 frames a second on this laptop. Time to give the smudges
skeletons.</p>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
        <item>
            <title><![CDATA[The Machine Grows Ears]]></title>
            <link>https://optic-yellow.com/blog/the-machine-grows-ears</link>
            <guid>https://optic-yellow.com/blog/the-machine-grows-ears</guid>
            <pubDate>Wed, 22 Jul 2026 18:00:00 GMT</pubDate>
            <description><![CDATA[The ball tracker blinks at the worst moments. Broadcast microphones don't. The software learns to listen for the pock of strings on ball.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top: a 15-shot rally from the Roland Garros final, with
the soundtrack drawn as a strip along the bottom. Green ticks mark the
moments the video pipeline detected a hit. Yellow spikes mark the
moments the microphone heard an impact. The red zones are the seconds
where the ball tracker recorded nothing at all — and the yellow spikes
keep firing right through them.</em></p>
<p>Today the software gets a second sense: when its eyes blink, can its
ears carry the point?</p>
<p><strong>Previously.</strong> The ball tracker — the tiny specialist network that
finds the ball in every frame (<a class="" href="https://optic-yellow.com/blog/the-zero-dollar-specialist">the zero-dollar
specialist</a>) — has one chronic
weakness: it blinks. On clay especially, its track is full of holes,
and every downstream call (hits, bounces, rally length,
<a class="" href="https://optic-yellow.com/blog/nobody-sees-the-ball-land">wide-or-deep</a>) inherits the
blindness. Last post, physics recovered the landing spots the eyes
never saw. This post goes after the holes themselves, with a witness
that doesn't have eyes at all.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-witness-that-never-blinks">The witness that never blinks<a href="https://optic-yellow.com/blog/the-machine-grows-ears#the-witness-that-never-blinks" class="hash-link" aria-label="Direct link to The witness that never blinks" title="Direct link to The witness that never blinks" translate="no">​</a></h2>
<p>Think about how you experience tennis from the cheap seats, or the next
room. You can follow the rhythm of a rally without seeing it: <em>pock —
pock — pock</em>. The strike of strings on a ball is one of the most
distinctive sounds in sport — sharp, short, and unlike anything else on
a tennis court. Every broadcast microphone picks it up, no matter where
the ball is, how small it looks on screen, or whether it just left the
frame. Crowd roar rises and falls slowly; commentary murmurs along; the
<em>pock</em> is a spike.</p>
<p>That's the whole idea: <strong>onset detection</strong> — scan the soundtrack for
sudden bursts of energy in the frequencies where racquet impacts live,
and mark each one's exact moment. No AI, no training: the software
slices the sound into 11-millisecond snippets, watches how much <em>new</em>
energy each snippet brings, and flags the spikes that tower over their
neighborhood. Notice the unit: 11 milliseconds. A video frame is 33.
The ears, when they're right, are three times more precise than the
eyes can ever be.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="first-problem-the-tapes-were-silent">First problem: the tapes were silent<a href="https://optic-yellow.com/blog/the-machine-grows-ears#first-problem-the-tapes-were-silent" class="hash-link" aria-label="Direct link to First problem: the tapes were silent" title="Direct link to First problem: the tapes were silent" translate="no">​</a></h2>
<p>One discovery before any listening could happen: <strong>every video this
project has ever downloaded is silent.</strong> The staging pipeline grabbed
video-only streams from day one — it never needed sound until today. So
the soundtracks had to be fetched fresh, audio-only, from the same
uploads. For the Roland Garros match the source was known. For the US
Open match it had to be re-found — and verified the way this project
verifies everything: by receipt. The candidate video's length matched
the match reel to a tenth of a second, and then the real proof: after
lining up the two recordings, a single fixed time-shift (95
milliseconds on one match, 310 on the other) snapped hundreds of
audio spikes onto video-detected hits at once. A wrong recording can't
fake that — there is no single shift that aligns someone else's match.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-the-ears-heard">What the ears heard<a href="https://optic-yellow.com/blog/the-machine-grows-ears#what-the-ears-heard" class="hash-link" aria-label="Direct link to What the ears heard" title="Direct link to What the ears heard" translate="no">​</a></h2>
<p>Two matches, both already graded against human charts: the US Open
final (hard court, where the eyes work well) and the Roland Garros
final (clay, where they don't).</p>
<table><thead><tr><th>Question</th><th>US Open (hard)</th><th>Roland Garros (clay)</th></tr></thead><tbody><tr><td>Video hits confirmed by an audio spike within 2 frames</td><td>282 of 645 — about 4 in 9</td><td>37 of 76 — about half</td></tr><tr><td>...within 3 frames</td><td>57%</td><td>66%</td></tr><tr><td>Impacts heard during the eyes' blind time</td><td>156 events in 67 seconds</td><td>196 events in 78 seconds</td></tr></tbody></table>
<p>Read that last row again, because it's the headline. Across the clay
match, the ball tracker spent 78 seconds of rally time seeing nothing —
and the microphone logged 196 impacts inside exactly those seconds.
The witness that never blinks works <em>best</em> precisely where the eyes
fail worst. That's what the yellow-spikes-in-red-zones moments in the
clip up top are showing.</p>
<p>And a small piece of physics showed up in the data, unasked. When the
detector is set strict — only the loudest spikes — the number of
impacts per shot settles at almost exactly <strong>two</strong>. Two impacts per
shot is precisely what a court hears: the racquet's <em>pock</em>, then the
bounce's <em>thud</em>. The microphone is telling us it hears both, cleanly
enough to count.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-honest-limit">The honest limit<a href="https://optic-yellow.com/blog/the-machine-grows-ears#the-honest-limit" class="hash-link" aria-label="Direct link to The honest limit" title="Direct link to The honest limit" translate="no">​</a></h2>
<p>Set looser, the ears hear too much. A tennis broadcast is full of
things that go <em>pock</em>: line calls, grunts, the umpire's microphone
thump, a ball kid's bounce, applause spikes. At the settings that catch
the most real hits, the detector logs about three impacts per shot —
the extra one is chatter. Asked to count rally lengths by ear alone
(spikes divided by two), it lands within one shot of the human chart
only 3 times in 10 on hard court, worse on clay.</p>
<p>So the verdict, stated precisely: <strong>audio v1 is a witness, not a
counter.</strong> It confirms moments and fills blind time with candidate
events; it can't yet tell a forehand from a line call. Teaching it the
difference — <em>pock</em> versus <em>thud</em> versus everything else — is a small
classification job, and the training data is free: thousands of
audio moments already labeled by the video hits they align with. That's
the same flywheel this project keeps returning to — one sense
teaching another, no human labeling anything.</p>
<p><strong>The takeaway:</strong> the machine's eyes blink, but the court always makes
the same two sounds per shot — and now the machine can hear them,
timed finer than its own eyes can see.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><p>The module is <code>experiments/audio_hits.py</code> (report:
<code>outputs/diag/audio_hits_report.txt</code>; run with
<code>PYTHONPATH=.:experiments uv run python experiments/audio_hits.py</code>).
Pure numpy — no librosa/scipy dependency.</p><p>Method: mono 22.05 kHz wav (yt-dlp bestaudio of the same uploads;
identity by duration fingerprint — g1 5039.40 s vs reel 5039.45 s
[known ID]; t6 2412.22 s vs 2412.13 s [condensed-match upload found by
search]). STFT 1024/256 hann; log-magnitude spectral flux over
1–8 kHz; normalized by sliding 2 s median/MAD; peaks above a z-score
THRESH with 0.10 s minimum separation. A/V offset per match by grid
search (±0.5 s, 5 ms steps) maximizing video hits matched within
40 ms: +95 ms (t6, 187/645 matched in search), +310 ms (g1, 34/76).</p><p>THRESH protocol: swept on t6 only (the calibration match), frozen
before grading g1. Sweep: z=5 → 44% hits matched @2fr, 3.27
onsets/true-shot; z=8 → 38%, 2.53; z=12 → 32%, <strong>2.00</strong> (the
racquet+bounce ratio); z=16 → 26%, 1.50; z=20 → 25%, 1.25. Frozen at
5.0 (highest within 5% of peak match rate). Naive rally length
(onsets/2, serve-to-last-hit window) within ±1 of MCP truth: 39/128
(30%) t6, 2/21 (10%) g1.</p><p>Hole metric: ball-track gaps &gt;3 frames between serve and last hit + 1 s;
onsets falling inside them after offset correction: 156 in 67 s (t6),
196 in 78 s (g1).</p><p>Staging finds, on the record: (1) <code>extract.py</code> cuts clips with <code>-an</code>
and ingest always fetched video-only — the whole corpus is silent by
construction; audio staging is now a first-class ingest step. (2) g1's
clip cutter (the <code>experiments/</code>-grade score-bug pipeline) never
persisted its reel windows; recovered because every clip measures
exactly 1.6 s longer than its alignment window (verified on four
clips), and the unknown pre/post pad split folds into the calibrated
A/V offset. Cutters must persist their windows — noted for the package
promotion.</p><p>Precedent: audio hit detection on broadcast tennis goes back to
APSIPA 2012 (Australian Open footage, F-score 68–77% audio-only);
Sony AI's table-tennis impact work (arXiv 2409.11760) reports
millisecond precision in controlled capture. Audio-visual fusion gains
on broadcast sports: arXiv 2011.04258. The v2 plan (racquet/bounce/
other mel-CNN on video-hit-aligned pseudo-labels) is blueprint roadmap
item 8's transcript-alignment flywheel applied to sound. Hero renderer:
<code>experiments/render_ears_hero.py</code> (g1_point_05, 15 shots, 4.0 s of
holes, 12 onsets inside them).</p></div></div></details>
<p>Session cost: $0.00 — the audio tracks were free, and everything runs
on the laptop. Project total: ~$16.</p>
<p>Next question: the ears confirm the eyes and cover their blinks, but
both witnesses still hand their evidence to a court fit that a human
clicked into place. Four corners, every match, by hand — the last big
manual knob on the machine. The soccer world already builds courts
that fit themselves and, more importantly, know when they've failed.
Time to steal that too.</p>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
        <item>
            <title><![CDATA[Nobody Sees the Ball Land]]></title>
            <link>https://optic-yellow.com/blog/nobody-sees-the-ball-land</link>
            <guid>https://optic-yellow.com/blog/nobody-sees-the-ball-land</guid>
            <pubDate>Wed, 22 Jul 2026 10:00:00 GMT</pubDate>
            <description><![CDATA[The machine couldn't tell wide errors from deep ones. The fix: stop trying to watch the bounce and calculate it, like Hawk-Eye does.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top: two real points from the US Open final. In each one,
the yellow dots are the machine watching the ball fly — until the dots
stop, mid-air, because the tracker loses it. Then the freeze-frame: a
white line extends the flight the machine did see, racing toward two
finish lines — the sideline and the baseline — and whichever it crosses
first names the error. Both calls match what the human charter wrote.</em></p>
<p>Today the software learns to answer a question it has been failing,
completely, since grading began: when a ball goes out, did it go out
<strong>wide</strong> or <strong>deep</strong>?</p>
<p><strong>Previously.</strong> This machine watches broadcast tennis and drafts a
shot-by-shot chart of every point, which then gets graded, correction
by correction, against charts written by human volunteers (<a class="" href="https://optic-yellow.com/blog/within-one-token-of-a-human">how the
grading works</a>). Most parts of the
draft have been inching upward for weeks. One part has not moved at
all: when a rally ends with a ball landing out, the machine scored <strong>zero
percent</strong> at saying which way it went out. Not "poor." Zero — across 159
tries, on eight matches, twice confirmed by different methods. The
reason is simple and physical: the ball tracker loses sight of the ball
a split second before an out-ball lands. Fuzzy yellow blur, busy
background, and the one moment that decides the call is exactly the
moment the machine goes blind.</p>
<p>The obvious fix — make the tracker hold on longer — is the one I'd been
planning. Then a research deep-dive turned up something better, hiding
in plain sight.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="hawk-eye-never-sees-the-ball-land-either">Hawk-Eye never sees the ball land either<a href="https://optic-yellow.com/blog/nobody-sees-the-ball-land#hawk-eye-never-sees-the-ball-land-either" class="hash-link" aria-label="Direct link to Hawk-Eye never sees the ball land either" title="Direct link to Hawk-Eye never sees the ball land either" translate="no">​</a></h2>
<p>Think about what Hawk-Eye actually shows you. That smooth animated arc,
the ball flattening onto the paint, the "OUT" verdict by two
millimeters — none of that is a photograph. No camera on Centre Court
captures the ball touching the ground; at that instant the ball is a
smear of pixels, half-hidden, moving at highway speed. What the system
photographs is the <strong>flight</strong>. The landing is <em>calculated</em> — physics,
fitted to the part of the flight the cameras saw clearly, finishes the
sentence.</p>
<p>A veteran line judge does the same thing. She doesn't wait to study the
skid mark. She reads the arc coming off the racket and knows the ball
is sailing long <em>before</em> it lands. The information was in the flight
the whole time.</p>
<p>That's the whole idea I stole this week: <strong>the boundary race</strong> — stop
trying to see the landing, and instead extend the flight the machine
<em>did</em> see until it crosses a line. If the extended path crosses the
<strong>sideline first</strong>, the ball was going wide. If it crosses the
<strong>baseline first</strong>, it was going deep. Whichever finish line the flight
reaches first names the error. (One more entry in the <a class="" href="https://optic-yellow.com/primer#boundary-race">plain-English
primer</a>, where the recurring ideas of this
series live.)</p>
<p>No new AI. No new camera. Straight-line physics, fitted to the last
stretch of dots the tracker recorded before it blinked — and two papers
at this year's top computer-vision conference validate the family of
idea, one of them placing landings within about 0.6 meters from a
single broadcast camera.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-happened-when-i-raced-it">What happened when I raced it<a href="https://optic-yellow.com/blog/nobody-sees-the-ball-land#what-happened-when-i-raced-it" class="hash-link" aria-label="Direct link to What happened when I raced it" title="Direct link to What happened when I raced it" translate="no">​</a></h2>
<p>I ran the race on every point in the benchmark — the eight matches
where human charters already wrote down the truth — and looked only at
the points that genuinely ended with a ball landing out: 169 of them,
of which 159 were plainly wide or deep (the rest were charted as both
at once).</p>
<table><thead><tr><th>Question</th><th>Before</th><th>Now</th></tr></thead><tbody><tr><td>Wide and deep errors named exactly right</td><td>0 of 159</td><td>75 of 159 — about half</td></tr><tr><td>Deep errors caught</td><td>0 of 90</td><td>55 of 90 — about 6 in 10</td></tr><tr><td>Wide errors caught</td><td>0 of 69</td><td>20 of 69 — about 3 in 10</td></tr><tr><td>When the race commits to wide-or-deep, how often is it right?</td><td>—</td><td>75 of 90 — about 5 in 6</td></tr></tbody></table>
<p>From nothing to half, with arithmetic. And when the race actually picks
a side — wide or deep, rather than hedging "both" or declining — it
separates the two at about five in six, on flights the tracker gave up
on.</p>
<p>The misses are honest and mostly one story: the TV camera looks down
the court, so a flying ball always <em>appears</em> deeper than it really is —
the same distortion that makes every approach shot look like it's
sailing long from your couch. That bias makes the baseline finish line
fire early, so some wide balls get called deep or "both." Fixing that
lean is a real project for later, not a knob to fudge today.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-the-video-caught-that-the-numbers-didnt">What the video caught that the numbers didn't<a href="https://optic-yellow.com/blog/nobody-sees-the-ball-land#what-the-video-caught-that-the-numbers-didnt" class="hash-link" aria-label="Direct link to What the video caught that the numbers didn't" title="Direct link to What the video caught that the numbers didn't" translate="no">​</a></h2>
<p>Rendering that clip up top wasn't decoration — it caught two real bugs
the scoreboard had quietly absorbed.</p>
<p>First: in one point, the machine's "last sighting" marker drew itself
at the top of the screen while the ball's trail exited bottom-right.
Impossible. The track data showed why: the ball supposedly moved 37
meters in three frames. Balls don't teleport — the tracker had lost the
real ball and latched onto something else, and those junk points were
poisoning the physics fit. The fix is a rule of nature, not a tuned
setting: end the flight at the first physically impossible jump.</p>
<p>Second: the race originally asked an earlier stage of the pipeline
which end of the court the final shot was heading toward — and that
stage was sometimes wrong. One point claimed the far player hit the
last shot, while the ball demonstrably died two meters beyond the <em>far</em>
baseline. Now the race reads the direction from the flight itself and
trusts nobody.</p>
<p>Both bugs were invisible in the accuracy tables and obvious in one
rendered frame. This series keeps re-learning the same lesson, so it's
earned a bolding: <strong>eyes beat derivatives</strong> — when a number and a
picture disagree, believe the picture.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-dead-end-kept">The dead end, kept<a href="https://optic-yellow.com/blog/nobody-sees-the-ball-land#the-dead-end-kept" class="hash-link" aria-label="Direct link to The dead end, kept" title="Direct link to The dead end, kept" translate="no">​</a></h2>
<p>One more result, because this devlog keeps its failures on the record:
I also tried the lazy version of "integration" — feeding the race's
outputs as extra hints into the existing ending classifier, the one
that learned from 11,646 human-charted matches. It did nothing. Zero
percent stayed zero.</p>
<p>The reason is instructive. Winners — balls that land <em>in</em> and end the
point — are nearly half of all endings, and thanks to that same
looks-deeper-than-it-is distortion, the race happily extends many
in-balls out too. A single flat classifier drowns the new signal in the
old base rates. The race's real gift is conditional: <em>given</em> that a
ball went out, it names the flavor. Deciding whether the ball went out
at all needs different witnesses — a net-death, an observed landing,
and soon (next on the roadmap) the sound of the ball itself. The
architecture lesson: don't ask one judge to answer two different
questions.</p>
<p><strong>The takeaway:</strong> the machine stopped trying to see the ball land, read
the flight instead — the way Hawk-Eye and every good line judge always
have — and half of a stone-blind category came back at the cost of
zero dollars and some algebra.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><p>The module is <code>experiments/landing_spot.py</code> in the court-vision repo
(report: <code>outputs/diag/landing_spot_report.txt</code>; run with
<code>PYTHONPATH=.:experiments uv run python experiments/landing_spot.py</code>).</p><p>Method (v1, deterministic, no constants fitted to truth): final flight
segment = contiguous ball-track run after the last charted contact,
capped at 2.0 s, cut at the first &gt;6-frame hole and at the first
teleport (&gt;3.0 m/frame court displacement — ~90 m/s, physics bound; the
same no-teleport gate <code>boxes.py</code> applies to players). Straight-line fit
cx(t), cy(t) over the last ≤8 samples in court coordinates; target half
inferred from the flight (already-out position wins, else the y-slope
sign, else which half the ball occupies). Race: time-to-sideline
(singles line ± the 0.25 m <code>OUT_MARGIN</code> from <code>endings.py</code>) vs
time-to-baseline, extrapolation capped at 0.7 s past track death;
crossings within 2 frames of each other → <code>x</code> (wide-and-deep); neither
within cap → abstain <code>?</code>.</p><p>Population: <code>evaluate.evaluate</code> records, truth = <code>mcp_ending_type</code>
(w/d/x), same denominators as <code>experiments/learn_components.py</code> plus
match #8 (g1). Full confusion (rows = truth):</p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">        w     d     x     ?</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  w    20    11    27    11   n=69</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  d     4    55    18    13   n=90</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  x     1     0     8     1   n=10</span><br></span></code></pre></div></div><p>Loose (called any out flavor): wide 58/69 (84%), deep 77/90 (86%).
Committed w-vs-d discrimination: 75/90 (83%). Per-match strict correct:
t7 27/58, t6 28/44, t5 10/29, t4 9/14, t3 4/10, g1 3/11, t1 1/2, t2
1/1 — clay (t3, g1) collapses because thin tracks leave no segment to
fit, which the planned clay fine-tune of the ball detector multiplies.</p><p>Version history within the session, all on the record: striker-derived
target + no teleport gate → wide 30/69, deep 35/90 (overall 41%);
+teleport gate → 31/69, 33/90 (41%); +flight-inferred target → 20/69,
55/90, overall 83/169 (49%), with the wide→x leak (27) being the
depth-inflation bias concentrating inside the 2-frame "both" window.</p><p>The refuted integration: appending racer features (call one-hot,
t_side, t_base, n_seg) to the 4-class leave-one-match-out random
forest (<code>learn_components.lomo</code>, seed 0) moved wide/deep recall 0%→1%
and cost overall accuracy (59.0%→57.4%). False out-calls on true
winners/net: 204/317 (64%) — the racer is a conditional classifier
P(wide vs deep | out), not an out-detector; it will enter the pipeline
as a per-slot likelihood in the planned grammar-constrained fusion
decoder, gated by independent out-ness evidence.</p><p>Literature anchors: TT3D (arXiv 2504.10035, CVSports @ CVPR 2025) and
"Where Is The Ball" (arXiv 2506.05763, same workshop; 0.63 m mean
landing error on broadcast tennis, 81 clips/10 matches). The hero video
renderer is <code>experiments/render_landing_hero.py</code> (points t6_point_110,
truth wide; t6_point_117, truth deep).</p></div></div></details>
<p>Session cost: $0.00. Project total: ~$16.</p>
<p>Next question: the race can name an out-ball's flavor, but something
still has to say "that ball went out" in the first place — and the
tracker's eyes have proven they blink at exactly the wrong moment. The
next witness doesn't have eyes at all. Every broadcast microphone
courtside hears the same thing you do: the <em>pock</em> of strings on ball.
Time to give the machine ears.</p>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
        <item>
            <title><![CDATA[The 33rd Charter]]></title>
            <link>https://optic-yellow.com/blog/the-33rd-charter</link>
            <guid>https://optic-yellow.com/blog/the-33rd-charter</guid>
            <pubDate>Sat, 11 Jul 2026 14:00:00 GMT</pubDate>
            <description><![CDATA[Court Vision introduces itself to the real volunteers who chart tennis matches by hand, and asks whether its drafts actually save them time.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top: the actual file, playing. All 134 points of the
Sabalenka–Pegula US Open final draft scroll past, and 39 light up
green — "trust this enough to correct instead of typing from
scratch," a claim that held up 94% of the time on matches the model
had never seen. Two green rows then play against their video clips,
draft string and jump-to timestamp on screen. It ends with what the
file admits it can't do yet, and a question for anyone who charts
tennis for real.</em></p>
<p>For sixteen posts, this project has graded its own homework — checking
drafts against charts it already had the answers to. Today, for the
first time, a draft goes to a real judge: someone who charts tennis
for a living, whose only job is to say whether it helped.</p>
<p>Previously: <a class="" href="https://optic-yellow.com/blog/auto-charting-kickoff">cv-01</a> set the destination —
the Match Charting Project (charters call it the MCP), run by Jeff
Sackmann of Tennis Abstract, is volunteers watching tennis and writing
down every shot in a compact code; nearly 18,000 matches charted so
far. <a class="" href="https://optic-yellow.com/blog/within-one-token-of-a-human">cv-14</a> gave the project a way
to grade a machine-written line against a human one: how many
corrections an editor would need, the <strong>token edit distance</strong>
(<a class="" href="https://optic-yellow.com/primer#token-edit-distance">more here</a>).
<a class="" href="https://optic-yellow.com/blog/the-draft-grades-itself">cv-15</a> turned that grade into a
stoplight — HIGH ("start from this draft") or LOW ("re-chart it") —
tuned so the flag never grades its own homework
(<a class="" href="https://optic-yellow.com/primer#confidence">more on confidence</a>).
<a class="" href="https://optic-yellow.com/blog/the-benchmark-grows-up">cv-16</a> grew the test to seven matches
across five broadcast feeds, 491 human-charted points, fixed a blind
spot on the Wimbledon match, and landed the number this post leans on:
HIGH is right about 94% of the time.</p>
<p>None of it has been true for long. In 2025 the MCP ran on <a href="https://www.tennisabstract.com/blog/2026/01/03/17000-matches/" target="_blank" rel="noopener noreferrer" class="">32 active
charters</a>,
covering about a quarter of tour matches — and Sackmann published a
plea for more. That's the door this post walks through.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-human-loop">The human loop<a href="https://optic-yellow.com/blog/the-33rd-charter#the-human-loop" class="hash-link" aria-label="Direct link to The human loop" title="Direct link to The human loop" translate="no">​</a></h2>
<p>Being graded correctly isn't the same as being useful. Groove a stroke
against a ball machine for months and you prove your technique
reproduces — never that it survives a live point, because a machine
can't return your shot and tell you how it felt. Every number this
project has published so far graded the pipeline's aim, not its use:
does the drafted string match a human's, was a flag right when checked
later. Nobody who charts a match for a living has ever opened one of
these files and tried to work from it.</p>
<p>That's the gap this post closes. The step where a tool's output leaves
the lab, lands on a real person's desk, and the only score that counts
is what they report back, is the <strong>human loop</strong>: grading against a
fixed answer stops, and getting judged by whether you saved someone
real time starts. Nothing in the pipeline changed this session — who's
on the other side of the net did.</p>
<p>In tennis terms: the ask is what a doubles player makes of a
prospective partner before ever stepping on court — play one set with
me, then tell me if I made you faster or slower.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-ships-today">What ships today<a href="https://optic-yellow.com/blog/the-33rd-charter#what-ships-today" class="hash-link" aria-label="Direct link to What ships today" title="Direct link to What ships today" translate="no">​</a></h2>
<p>Seven files went public today, one per benchmark match, in the exact
column layout the MCP already uses for its own point-by-point files.
The familiar columns are all there — match, point number, set score,
game score, point score, server — a real charter could read those
blind. Then come the columns Court Vision added:</p>
<table><thead><tr><th>Column</th><th>What's in it</th><th>What it means</th></tr></thead><tbody><tr><td><code>1st</code></td><td>e.g. <code>s5b2f1f2f2x@</code></td><td>the machine's guess at the point, serve to last shot, in a charter's own shorthand</td></tr><tr><td><code>2nd</code></td><td>always blank</td><td>can't yet tell a first serve from a second, so second-serve rallies are simply missing</td></tr><tr><td><code>confidence</code></td><td>HIGH or LOW</td><td>HIGH: trust it enough to correct. LOW: rough hint, expect a rewrite</td></tr><tr><td><code>conf_p</code></td><td>e.g. <code>0.902</code></td><td>the raw score behind the label — closer to 1 is more confident</td></tr><tr><td><code>clip</code></td><td>a filename</td><td>which extracted broadcast clip the row came from</td></tr><tr><td><code>serve_s</code></td><td>seconds</td><td>where the serve happens in that clip — the jump-to</td></tr><tr><td><code>n_shots</code></td><td>a small number</td><td>shots in the point, serve included</td></tr></tbody></table>
<p>A real row, from the US Open final, flagged HIGH at 0.902:
<code>s5b2f1f2f2x@</code>. Read left to right: <code>s5</code> — serve into the body. <code>b2</code> —
backhand return, up the middle. <code>f1</code>, <code>f2</code>, <code>f2</code> — three forehands,
one into an outer third, then twice down the middle. <code>x@</code> — unforced
error, wide and deep. Six tokens, one point.</p>
<p>Every match's file, with how many points it drafted and how many got
the green flag:</p>
<table><thead><tr><th>Match</th><th>Points drafted</th><th>Flagged trustworthy</th></tr></thead><tbody><tr><td><a href="https://optic-yellow.com/exports/t1_mcp_draft.csv" target="_blank" rel="noopener noreferrer" class="">Nadal vs. Shapovalov, Montreal 2017</a></td><td>24</td><td>10</td></tr><tr><td><a href="https://optic-yellow.com/exports/t2_mcp_draft.csv" target="_blank" rel="noopener noreferrer" class="">Federer vs. Haase, Montreal 2017</a></td><td>6</td><td>4</td></tr><tr><td><a href="https://optic-yellow.com/exports/t3_mcp_draft.csv" target="_blank" rel="noopener noreferrer" class="">Djokovic vs. Ruud, French Open final 2023</a></td><td>59</td><td>12</td></tr><tr><td><a href="https://optic-yellow.com/exports/t4_mcp_draft.csv" target="_blank" rel="noopener noreferrer" class="">Krejcikova vs. Paolini, Wimbledon final 2024</a></td><td>49</td><td>18</td></tr><tr><td><a href="https://optic-yellow.com/exports/t5_mcp_draft.csv" target="_blank" rel="noopener noreferrer" class="">Sinner vs. Zverev, Australian Open final 2025</a></td><td>71</td><td>3</td></tr><tr><td><a href="https://optic-yellow.com/exports/t6_mcp_draft.csv" target="_blank" rel="noopener noreferrer" class="">Sabalenka vs. Pegula, US Open final 2024</a></td><td>134</td><td>39</td></tr><tr><td><a href="https://optic-yellow.com/exports/t7_mcp_draft.csv" target="_blank" rel="noopener noreferrer" class="">Djokovic vs. Sinner, ATP Finals 2023</a></td><td>165</td><td>13</td></tr><tr><td><strong>Total</strong></td><td><strong>508</strong></td><td><strong>99</strong></td></tr></tbody></table>
<p>99 of 508 — about one point in five — got the green flag; cv-16 found
it right about 94% of the time on matches the grading model never
trained on. Start with the US Open final: best hit rate in the batch,
and the model called who served right 95% of the time there too — a
camera that holds still pays off.</p>
<p>A drill doesn't prove anything until it's played against a real
opponent. That's the whole reason this post exists.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-honest-label">The honest label<a href="https://optic-yellow.com/blog/the-33rd-charter#the-honest-label" class="hash-link" aria-label="Direct link to The honest label" title="Direct link to The honest label" translate="no">​</a></h2>
<p>A draft that hides its weak spots wastes an hour, so the weak spots
come first.</p>
<p><strong>The drafts are drafts.</strong> Across all 491 human-charted points behind
the whole benchmark, only 5.7% — roughly 1 in 18 — match the human's
line closely enough to need just one fix. The real value sits in the
green rows: HIGH means within five corrections, holding up 94% of the
time on matches the grading model never trained on (96% on matches it
did study — the friendlier, less trustworthy number, included
anyway). LOW, 409 of 508 rows, means exactly what it sounds like:
re-chart from the video, draft as hint, <code>serve_s</code> as the jump-to.</p>
<p><strong>Every missed serve is invisible.</strong> The pipeline can't yet see a
fault, so every point is written as if won on the first serve, and the
second-serve column is empty on all 508 rows. On a real scoresheet,
roughly a third of points start with a miss. Fixing that isn't a
footnote of the charter's pass — it's most of it.</p>
<p><strong>The shorthand isn't official yet.</strong> The <code>s</code> before a serve number is
the pipeline's own invention, not real MCP notation, and a <code>?</code> shows
up when the machine can't tell which way a shot went. The draft also
skips most of what a full MCP chart captures — no depth, no court
position, no volley vs. lob vs. drop shot; endings and rough
directions only. Whether to write strict, official strings with
blanks instead, or keep the uncertainty visible, is undecided —
question three, below.</p>
<p><strong>Seventeen rows have no point number.</strong> The machine caught the point
on video but couldn't match it to a spot on the scoreboard, so that
column is blank. The point happened; a person still has to say where
it belongs.</p>
<p><strong>One answer key has problems of its own.</strong> cv-16's autopsy caught the
Wimbledon final's human chart running at half speed on some rallies —
strokes written down that the video contradicts. That file still
ships, 18 of its 49 rows green. Open that one first if you're hunting
for the model being too sure of itself.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-ask">The ask<a href="https://optic-yellow.com/blog/the-33rd-charter#the-ask" class="hash-link" aria-label="Direct link to The ask" title="Direct link to The ask" translate="no">​</a></h2>
<p>If you chart for the MCP — or ever have — three questions matter here,
in the order they'd change what gets built next.</p>
<ol>
<li class="">
<p><strong>The stopwatch.</strong> Chart ten points cold, straight off a clip.
Then correct ten different green-flagged drafts against their
clips instead. Which pass is faster, and by how much? The whole
project is a bet on that ratio, never yet measured by anyone who
actually holds the pencil.</p>
</li>
<li class="">
<p><strong>The triage.</strong> Is a draft right 94% of the time, within five
corrections, the right bar for "start from this"? And with only
about one point in five earning that flag, does a pre-filled file
even help mid-session — or does switching between "check this" and
"chart from nothing" cost more than it saves?</p>
</li>
<li class="">
<p><strong>The schema.</strong> What would this file need before you'd reach for it
mid-session — official MCP strings with blanks, a real fault
column, a charting-sheet layout instead of a spreadsheet, something
else entirely?</p>
</li>
</ol>
<p>The channel is <a href="https://github.com/TrevorMcCormick/court-vision/issues" target="_blank" rel="noopener noreferrer" class="">an issue on
court-vision</a>
— a one-line "this wastes my time because X" is signal, not rudeness.
It would be the single most valuable sentence anyone has contributed
to this project.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="lineage-and-license">Lineage and license<a href="https://optic-yellow.com/blog/the-33rd-charter#lineage-and-license" class="hash-link" aria-label="Direct link to Lineage and license" title="Direct link to Lineage and license" translate="no">​</a></h2>
<p>Worth knowing before opening these files: the score and server columns
exist because these drafts were lined up against the MCP's own
published charts — the same charts the 94% number is graded against.
So these files carry the MCP's license,
<a href="https://github.com/JeffSackmann/tennis_MatchChartingProject" target="_blank" rel="noopener noreferrer" class="">CC BY-NC-SA 4.0</a>:
credit the source, don't sell it, share what's built from it the same
way. The draft strings and confidence columns are this project's own
addition, released under that license to keep the files one whole
thing.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><p>Exact numbers, for anyone doing their own math:</p><ul>
<li class="">File key (<code>t1</code>–<code>t7</code>, internal match IDs, same order as the table
above): t1 Nadal–Shapovalov, t2 Federer–Haase, t3 Djokovic–Ruud
(clay), t4 Krejcikova–Paolini (grass), t5 Sinner–Zverev (night), t6
Sabalenka–Pegula, t7 Djokovic–Sinner (indoor). Points/HIGH per match:
table above.</li>
<li class=""><code>match_id, Pt, Set1/Set2, Gm1/Gm2, Pts, Svr, 1st/2nd</code> — MCP's own
points-file columns, preserved as-is.</li>
<li class="">HIGH tier LOMO figure: 90/96 at 19.6% coverage.</li>
<li class=""><a href="https://github.com/TrevorMcCormick/court-vision" target="_blank" rel="noopener noreferrer" class="">court-vision</a> is
public; run cost ~$16 across seventeen posts, marginal match $0.</li>
</ul></div></div></details>
<p>Session cost: $0.00 — nothing ran but a video renderer. Project total:
~$16. The next number that matters isn't the pipeline's to produce:
it's one charter's minutes-per-point, with and without the draft.</p>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
        <item>
            <title><![CDATA[The Benchmark Grows Up]]></title>
            <link>https://optic-yellow.com/blog/the-benchmark-grows-up</link>
            <guid>https://optic-yellow.com/blog/the-benchmark-grows-up</guid>
            <pubDate>Sat, 11 Jul 2026 02:00:00 GMT</pubDate>
            <description><![CDATA[A bigger test made the trust score honestly drop — then a check that could never fail got caught, and the score climbed back up.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top: watch the test set grow, tile by tile, from four
matches to seven — the point counter climbing from 135 to 491
human-charted points. Then its self-reported trust score recalculates
live on screen — down, then back up — as the reason for the dip gets
found and fixed. It closes on a real point
from the Sabalenka–Pegula U.S. Open final: a serve into the body, a
backhand up the middle, a forehand rally ending wide and deep —
flagged correctly trustworthy at a 0.902 score by a version of the
tool that had never watched that match before.</em></p>
<p>Would you trust a player's world ranking more if it came from one
dazzling week at one tournament, or from a whole season played across
different surfaces and different opponents?</p>
<p>Two posts back, <a class="" href="https://optic-yellow.com/blog/within-one-token-of-a-human">cv-14</a> gave the
project its scorecard: count how many fixes — changes, insertions,
deletions — a human editor would need to turn the machine's chart
into the real one — an <a class="" href="https://optic-yellow.com/primer#token-edit-distance">acceptance</a> at
one fix or fewer. <a class="" href="https://optic-yellow.com/blog/the-draft-grades-itself">cv-15</a> then built a trust flag on top
of that scorecard: every drafted point gets stamped HIGH or LOW, and
HIGH means "start correcting from this draft." But that flag had only
ever been tested against 135 points from four matches — barely 1 in 9
of which passed the one-fix bar to begin with — and cv-15 signed off
with HIGH at 93% reliable, plus a warning: 135 points wasn't enough
to trust yet, and three more matches were already on the way.</p>
<p>Here's why that matters. A hot week can flatter a ranking — a small
field, a favorable draw, a home crowd — and it isn't wrong exactly,
it's just thin. A full season is the harder test: more matches,
tougher and more varied opponents, and the ranking is allowed to go
down afterward even though the player didn't get worse. It's finally
being asked to hold up over more evidence, and a ranking that
survives a full season is worth more than a flashier one built on a
single great week, precisely because it was harder to earn.</p>
<p>That's <strong>recalibration</strong>: redoing the trust-score math against a
bigger, tougher set of examples, and trusting the number wherever it
lands — even down. (<a class="" href="https://optic-yellow.com/primer#confidence">More on how these trust scores get
built.</a>) The confidence flag lives or dies on two
numbers: how often it's even willing to say HIGH at all — called
<em>coverage</em> — and how often HIGH is actually right when it does —
called <em>precision</em>. It's the same trade a line judge faces: call
everything close and you're helpful but wrong a lot; call only the
obvious ones and you're always right but useless. The cv-15 flag had
only faced one hot tournament's worth of evidence. This session gave
it a full season.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="three-more-finals-same-machine">Three more finals, same machine<a href="https://optic-yellow.com/blog/the-benchmark-grows-up#three-more-finals-same-machine" class="hash-link" aria-label="Direct link to Three more finals, same machine" title="Direct link to Three more finals, same machine" translate="no">​</a></h2>
<p>The pool of candidates was every match the Match Charting Project's
volunteers have hand-charted — this project's answer key, <a class="" href="https://optic-yellow.com/primer#ground-truth">explained
here</a> — cross-checked against what's findable
on YouTube. The winning format was the "condensed match" upload: a
20-to-40-minute re-cut that keeps nearly every point and skips only
the between-point filler, which lines up with the human chart far
better than a highlights reel. Three finals made the cut: the
Australian Open final at night, the U.S. Open final, and the ATP
Finals indoors in Turin — 575 more charted points to draw from.</p>
<p>All three ran through the pipeline's existing machinery untouched —
no new code, just settings. Each broadcast had one quirk: a court's
blue paint too bright for the usual color threshold, a green sideline
apron too close to the court's own color, a hazy light-blue apron
needing a brighter ceiling — all one-line settings fixes. One old
lesson got relearned, too: the recipe that converts a screen position
into a court position — the <a class="" href="https://optic-yellow.com/primer#homography">homography</a> — has to
be built from footage where the camera is actually holding still. The
first attempt smeared because the camera was still panning; a static
five-second window during the serve toss fixed it.</p>
<p>New broadcasts also surfaced a new headache and caught an old bug.
The headache: all three hide the <a class="" href="https://optic-yellow.com/primer#score-bug">scoreboard
graphic</a> at 0-0, briefly confusing the tool that
uses it to tell points apart — solved by cross-checking the human
charter's own serve notes. The bug: the method for tracking which end
of the court a player serves from had been quietly miscounting
tiebreak games as a full 13th game instead of the shorter,
differently-scored game it actually is — a one-line fix that flipped
a whole match's worth of serve-end guesses from mostly wrong to
mostly right. And on the record: an overnight background job died
silently, and two matches' worth of finished work sat untouched until
someone checked by hand.</p>
<p>None of that dented the results. Scored against the pipeline's
unchanged rules, the three new matches set new highs: the U.S. Open
match got the serving end right 121 times out of 128 (95%), and
Turin got rally length right, give or take one shot, 131 times out of
157 (83%). On the strict pass-fail test, the bigger set held
essentially flat: 28 of
491 points needed no correction at all (5.7%), close to the earlier 7
of 135 (5.2%). The pipeline didn't get worse by testing it harder.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-honest-number-goes-down-on-purpose">The honest number goes down, on purpose<a href="https://optic-yellow.com/blog/the-benchmark-grows-up#the-honest-number-goes-down-on-purpose" class="hash-link" aria-label="Direct link to The honest number goes down, on purpose" title="Direct link to The honest number goes down, on purpose" translate="no">​</a></h2>
<p>On the small set — 135 points, four matches — the confidence flag's
HIGH rating was right 93% of the time, willing to call HIGH on 32.6%
of points. Recalculated on the full season — 491 points, seven
matches, five broadcasts, same math, no changes — the honest numbers
came in lower: 88% right, called on 21.2% of points. That's not the
tool getting worse. That's small-sample flattery washing out.</p>
<p>One match makes the point almost too neatly. In the small test, the
Wimbledon match looked flawless: 11 for 11, 100% right, the best
result in the table. In the big test, with more than double the
Wimbledon points to check, that same match fell to 69% right while
claiming to flag more than half the match. The small test wasn't
rigged — it just didn't have enough Wimbledon points yet to catch
what was wrong. More data didn't break Wimbledon's score. It let the
project finally see it, and go looking for why.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-wimbledon-blind-spot">The Wimbledon blind spot<a href="https://optic-yellow.com/blog/the-benchmark-grows-up#the-wimbledon-blind-spot" class="hash-link" aria-label="Direct link to The Wimbledon blind spot" title="Direct link to The Wimbledon blind spot" translate="no">​</a></h2>
<p>The why turned out to be a single broken check.</p>
<p>cv-15's best idea was a serve check: a real toss lands the ball's
launch point more than 20 meters beyond the baseline, so a "serve"
that starts from inside the court is really a broadcast cutting into
the middle of an already-running point — and the draft gets marked
down for it. On its own, it had been the confidence project's single
biggest improvement.</p>
<p>But it only works when the system identified the serve by watching
the ball's own flight. When the ball-tracking data is too spotty to
trust, it falls back to guessing the serve from the players' stance
and position instead — and on the Wimbledon broadcast, every single
serve was identified that second way. So the serve check had zero
real serves of its kind to examine, for an entire tournament's worth
of points. It wasn't passing every point because it checked them and
they held up; it had nothing to check, and defaulted to "trust it"
silently, every time.</p>
<p>Under that free pass, three failures went unnoticed. One point's
drafted chart recorded 8 shots, evenly spaced about a second and a
half apart — but the real footage runs twice as fast, a shot every
0.9 seconds, meaning the shot-detector had missed every other stroke.
It still looked trustworthy because the who's-hitting-next check
happened to add up anyway — the same ball-tracking blind spots hit
both checks the same way, so the errors canceled out instead of
standing out. A second clip opens
on a slow crossfade into a rally already underway — five real seconds
of tennis before the stroke the stance method called "the serve."
<a class="" href="https://optic-yellow.com/blog/the-draft-grades-itself">cv-15</a> had already caught this trick
once, on the clay-court match, where the broadcast cut hard into the
middle of a point; Wimbledon's editor does the same thing with a fade
instead of a cut — exactly what the (silently broken) serve check
exists to catch. And a third case: a four-shot rally in the human's
chart where the ball-tracking data never shows a single net crossing
— the <a class="" href="https://optic-yellow.com/primer#hits-and-bounces">ball's path across the net</a> is a
rally's skeleton, and this rally had none.</p>
<p>The fix wasn't to patch the broken check. It was to add two checks
that look at the whole point instead of just the serve. First: does
the ball cross the net, at rally speed, before the moment the chart
claims the serve happened? If so, the clip started mid-rally already
— catching both the hard-cut and the fade. Second: does the charted
shot sequence show any net crossings in the tracking data at all? No
crossings, no trust, however many shots the chart claims. Swapping
the one broken check for these two took Wimbledon's held-out score
from 69% to 85%, and the project's overall number from 88% to 94%,
while cutting the worst-case disaster flags roughly in half.</p>
<p>The project already learned this lesson once, in a different
costume. Back in <a class="" href="https://optic-yellow.com/blog/within-one-token-of-a-human">cv-14</a>, the
machine's shot-direction calls came out backwards for a large share
of points, because the code assumed a fixed side of the court instead
of checking which end the receiver was standing at — nobody noticed
until the scorecard itself gave the bug away. The Wimbledon check is
the same mistake in a different jersey: <strong>a trust score is only as
honest as its least-examined assumption.</strong> The project's best idea
had quietly been a rubber stamp on one entire broadcast. The audit
question was never "is this check smart" — it's "can this check even
fire, here, on this footage."</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="where-the-honest-number-landed">Where the honest number landed<a href="https://optic-yellow.com/blog/the-benchmark-grows-up#where-the-honest-number-landed" class="hash-link" aria-label="Direct link to Where the honest number landed" title="Direct link to Where the honest number landed" translate="no">​</a></h2>
<table><thead><tr><th>Test round</th><th>Willing to say HIGH (coverage)</th><th>Right when it did (precision)</th></tr></thead><tbody><tr><td>Small test — 135 points, 4 matches (cv-15)</td><td>32.6%</td><td>93%</td></tr><tr><td>Big test — 491 points, 7 matches, before the Wimbledon fix</td><td>21.2%</td><td>88%</td></tr><tr><td>Big test — 491 points, 7 matches, after the Wimbledon fix</td><td>19.6%</td><td>94%</td></tr></tbody></table>
<p>The trade for that last jump is real: 1.6 fewer points of coverage,
for a genuinely more honest number — the right trade every time. See
<a class="" href="https://optic-yellow.com/primer#confidence">Court Vision in Plain English</a> for why coverage
and precision have to be read together.</p>
<p>A let-cord sensor that never buzzes isn't proof of a clean serve. It
might just be unplugged — and the only way anyone finds out is by
checking whether it could have buzzed at all.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><p><strong>The three matches.</strong> t5 = Sinner–Zverev, Australian Open final 2025
(hard, night, AO feed). t6 = Sabalenka–Pegula, US Open final 2024
(hard, WTA, USO feed). t7 = Djokovic–Sinner, ATP Finals 2023 (indoor
hard, Tennis TV feed). 575 MCP points on the table, 372 clips
extracted, 356 scored.</p><p><strong>Config-only integration.</strong> Every match ran through the existing
<code>fitcourt</code> / <code>probe</code> / <code>extract</code> / <code>boundaries</code> / <code>align</code> stages plus
one YAML per match — no script forks. Fixes landed in <code>court_detect</code>
config: t5's bright coating starved the tophat threshold default
(raised); t6's green apron shares an HSV band with its blue court (a
second hull band, OR'd in); t7's light-blue apron rides V≈252 (value
ceiling opened). Homography fit window: t5's first window carried
26.5 px of camera pan smear, fixed via a static 5-second serve-setup
window (≤0.2 px residuals).</p><p><strong>Zero-valued columns.</strong> All three broadcasters hide the score-bug at
0-0, which splits game-start points into duplicate-score fragments.
Adjudication: MCP's own serve columns arbitrate most groups (a human
row reading <code>4w</code> plus a second-serve rally means the fragment pair is
one point, serve in the later piece); the rest fell to changeover
parity and eyeballed frame strips. Losers get blanked so the eval
skips rather than double-charges.</p><p><strong>Tiebreak parity bug.</strong> The staged prior counted Turin's set 2 as 12
games + a tiebreak = 13 swap-units (odd). Wrong: a 7-4 breaker is 11
points with one internal end-change at 6, and the set-end change
cancels it — even parity. The set-3 server-end vote had come back 17
agree / 37 disagree, systematically flipped; correcting the prior
flipped it to 37/17, verified on video.</p><p><strong>Process failure.</strong> The run stalled overnight: the background-job
watcher died silently between t6 ball tracking and t7 players jobs —
both finished fine, nothing was listening. The rest of the run polled
jobs directly instead of trusting completion callbacks.</p><p><strong>Per-match scorecard, same eval, same constants:</strong></p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">metric              t5 AO night    t6 USO WTA      t7 Turin indoor</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">server end          53/71  (75%)   121/128 (95%)   133/157 (85%)</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">rally length ±1     47/71  (66%)    99/128 (77%)   131/157 (83%)</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">letters (aligned)   93/117 (79%)   120/148 (81%)   112/157 (71%)</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">acceptance ≤1 edit   2/71          10/128            9/157</span><br></span></code></pre></div></div><p>Pooled acceptance: 28/491 (5.7%), up from 7/135 (5.2%).</p><p><strong>Full leave-one-match-out table, before the Wimbledon fix:</strong></p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">LOMO (held out)   high prec (≤5 edits)   coverage</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">t1 night              10/10 (100%)         45.5%</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">t2 ctrl                 3/3 (100%)         60.0%</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">t3 clay               11/12  (92%)         20.3%</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">t4 grass              18/26  (69%)         53.1%</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">t5 AO                   3/4  (75%)          5.6%</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">t6 USO                34/35  (97%)         27.3%</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">t7 Turin              13/14  (93%)          8.9%</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">pooled                92/104 (88%)         21.2%</span><br></span></code></pre></div></div><p>The strict ≤2-edit tier was re-attempted at n=491 and still dies in
leave-one-match-out (0% coverage at a 19% base rate) — not shipped.</p><p><strong>The t4 (Wimbledon) autopsy.</strong> Eight false-highs, each pulled apart
against its chart CSV, the human's string, and frame strips. The
launch-plausibility gate (cv-15's best find) only inspects ball-called
serves; every t4 serve is stance-called; the gate was vacuous on the
entire feed, contributing +0.59 logit of unearned trust per point.</p><p>Three named mechanisms: (1) <code>t4_point_02</code>, the half-cadence chart — 8
shots at 1.5s spacing against a real ~0.9s cadence, striker chain
alternating cleanly because the ball track is blind in the same
places the hit detector is; (2) the dissolve-cut mid-rally join — a
crossfade into a live rally, 5 seconds of tennis before the stroke
the stance detector blessed as "the serve"; (3) the spineless rally —
a 4-shot chart with zero net crossings even at the weak gates.</p><p>Two dead ends, kept: weak-gated crossings don't recover the missing
strokes (point_02's 17-stroke rally yields 7 weak crossings); charted
cadence is real but soft (AUC 0.62, marginal flags 50/50) — a sidecar
column, not a model feature.</p><p><strong>What shipped:</strong> <code>xr_pre_serve</code> (rally-speed net crossings ending
before the charted serve) and <code>rally_spineless</code> (a 3+-shot chart with
no spine at all).</p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">LOMO (held out)      before                after</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">t4 grass         18/26 (69%) @ 53.1%   17/20  (85%) @ 40.8%</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">pooled           92/104 (88%) @ 21.2%  90/96  (94%) @ 19.6%</span><br></span></code></pre></div></div><p>No other fold fell below 92%. High-tier disasters halved, 12 → 6. The
trade was 1.6 points of coverage. Residual: <code>t4_point_11</code> stays
flagged at 10 edits — its dissolve-cut leaves no pre-serve crossings
because the track is blind there too.</p><p><strong>Final refit.</strong> The shipped scorer refit on all 491 flags 97 points
high at 96% in-sample; exports regenerated for all seven matches —
508 draft points, 99 flagged high.</p></div></div></details>
<p>Where things stand now: seven matches, five broadcasts, 491
human-charted points on the books, and a HIGH flag that means "start
from this draft" and is right about 94% of the time on the honest,
held-out test — for roughly 1 in 5 points confident enough to earn
the flag at all. The benchmark finally has the size its own warnings
had been asking for.</p>
<p>Session cost: $0.00 — three tournament finals' worth of brand-new
answer-key data, added for zero marginal dollars. Project total so
far: about $16.</p>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
        <item>
            <title><![CDATA[The Draft Grades Itself]]></title>
            <link>https://optic-yellow.com/blog/the-draft-grades-itself</link>
            <guid>https://optic-yellow.com/blog/the-draft-grades-itself</guid>
            <pubDate>Fri, 10 Jul 2026 02:00:00 GMT</pubDate>
            <description><![CDATA[A confidence flag tells a volunteer chart-corrector which draft points to trust — and this post checks whether the flag itself can be trusted.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top: the machine's actual draft of the Roland-Garros
final, point by point — each gets a chart string, a confidence score,
and a flag, HIGH rows glowing green. Three flagged points then play
back with their draft on screen, from a clean ace (<code>s4*</code>: serve out
wide, unreturned, 0.969 confidence) to a messy point full of "not
sure" marks (0.933 confidence). The running tally: 59 points drafted,
16 flagged "start from this draft."</em></p>
<p>Say a machine hands a volunteer chart-corrector a stack of drafted
points — how's she supposed to know, before she's watched a single
one, which drafts are worth trusting and which she should chart from
scratch?</p>
<p>Previously: <a class="" href="https://optic-yellow.com/blog/within-one-token-of-a-human">cv-14</a> gave the
project its report card — the <a class="" href="https://optic-yellow.com/primer#token-edit-distance">token edit
distance</a>, how many fixes an editor needs
to turn the machine's chart into a human's, a point "accepted" at one
fix or fewer. <a class="" href="https://optic-yellow.com/blog/t1-answers-in-the-back">cv-10</a> set the rule for
grading any of it: score only against matches a human already
charted, never a match the pipeline saw while being tuned. And since
<a class="" href="https://optic-yellow.com/blog/m3-who-hit-that">cv-05</a>, players get found for free by
<a class="" href="https://optic-yellow.com/primer#background-subtraction">background subtraction</a> — photograph
an empty court, call anything different in a later frame a person.
Today tests whether that free method is good enough, then teaches the
draft to flag its own reliability.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-confidence-flag-is-a-line-call">A confidence flag is a line call<a href="https://optic-yellow.com/blog/the-draft-grades-itself#a-confidence-flag-is-a-line-call" class="hash-link" aria-label="Direct link to A confidence flag is a line call" title="Direct link to A confidence flag is a line call" translate="no">​</a></h2>
<p>Picture two line judges. The first only calls a ball "out" when it
lands a clear foot past the line. She's almost never wrong, but
rarely speaks, leaving a player who trusts only her guessing on
everything else. The second judge calls anything that looks close —
she catches nearly everything worth catching, but plenty of her calls
are wrong on replay.</p>
<p>A confidence flag sits on that same spectrum, and two numbers say
where. <strong>Coverage</strong> is how often it's willing to call HIGH at all —
how often the strict judge speaks. <strong>Precision</strong> is how often a HIGH
call is actually right. A flag that's always right but rarely fires
isn't useful, and neither is one that fires constantly and is often
wrong — you need both numbers together. (<a class="" href="https://optic-yellow.com/primer#confidence">More in the
primer.</a>)</p>
<p>Making those numbers honest takes a third idea: <strong>calibration</strong>. Take
points a human volunteer has already charted — the <a class="" href="https://optic-yellow.com/primer#ground-truth">answer
key</a> — and check signals the pipeline keeps
about its own effort: did the serve call commit cleanly? does the
ball's path have gaps? did two vote-counting methods for "who hit
that" agree? Fit those signals to the real outcomes and the fit
becomes a small scoring formula.</p>
<p>The trap: a formula that peeks at a match's answers while being built
will always look brilliant tested on that same match — an open-book
quiz, not a test. So fit it on three matches, then grade it, cold, on
the fourth it's never seen, rotating until every match gets a turn
held back. Tennis translation: <strong>the system never grades its own
homework</strong> — the same rule cv-10 set for the pipeline, now applied to
the flag that grades its work.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="is-the-pricier-player-finder-worth-12">Is the pricier player-finder worth $12?<a href="https://optic-yellow.com/blog/the-draft-grades-itself#is-the-pricier-player-finder-worth-12" class="hash-link" aria-label="Direct link to Is the pricier player-finder worth $12?" title="Direct link to Is the pricier player-finder worth $12?" translate="no">​</a></h2>
<p>Today's first question: how much of the draft's remaining mistakes
trace back to the free player-finder misjudging where people stand?</p>
<p>Every shot call — forehand or backhand, roughly which third of the
court — comes from one rule: whichever side of the player's outline
the ball arrives at, that's the shot. A bad outline poisons the call,
already the biggest single source of remaining edits, about 1.7
corrections per point. Sorting every scored shot by outline quality at
contact:</p>
<table><thead><tr><th>player outline at contact</th><th>shot called right</th><th>shot called wrong</th><th>system gave up</th></tr></thead><tbody><tr><td>clearly fine</td><td>70</td><td>17</td><td>11</td></tr><tr><td>shaky</td><td>31</td><td>18</td><td>23</td></tr><tr><td>missing entirely</td><td>0</td><td>0</td><td>8</td></tr></tbody></table>
<p>Nearly half of every shot call — 45% — was riding on a shaky or
missing outline; accuracy was 71% on clean outlines, 39% on shaky
ones. Ceiling: about 26 more correct calls.</p>
<p>Free cleanup — rejecting teleporting outlines, checking court-half,
filling short gaps, widening for legs-only silhouettes — bought back 3
of those 26: about an eighth of the ceiling. The rest was missing
data, not noisy data: the far player usually isn't in the free
tracker's output near contact. No cleanup invents a player the tracker
never saw.</p>
<p>That earned the right to spend money: SAM 3 — the promptable AI that
tracked this project's first ball, back in
<a class="" href="https://optic-yellow.com/blog/m0-first-tracked-rally">cv-02</a> — re-found both players in the
Roland-Garros match only, prompted from the free tracker's own boxes,
for about $12. Shot-call accuracy went from 59% to 67%, mostly
outlines that had been missing — 21 calls flipped right, one point
gaining seven alone.</p>
<p>It still didn't move the number that matters — full points a
volunteer would sign off with one fix or fewer, stuck at 3 of 59. A
better shot call wasn't holding those points back, and the paid
tracker inherited the blind spot it was hired to fix: where the free
tracker drew no usable box, an automatic prompt had nothing to point
at either. Verdict: the $0 tracker stays default — what every
single-piece upgrade has said since cv-14: improving one ingredient
nudges the score without changing which points a human would accept.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="one-tool-not-twenty-scripts">One tool, not twenty scripts<a href="https://optic-yellow.com/blog/the-draft-grades-itself#one-tool-not-twenty-scripts" class="hash-link" aria-label="Direct link to One tool, not twenty scripts" title="Direct link to One tool, not twenty scripts" translate="no">​</a></h2>
<p>Before any of that shipped, this project's first fourteen posts'
worth of scattered experiment scripts — twenty-odd files, four
near-identical chart-builders kept in sync by hand — got folded into
one package: one chart-builder, one grading tool, one command-line
tool, broadcaster differences moved into a settings file per match.
Condition for merging: it had to reproduce every number this series
has published, byte for byte, before anything new shipped on top. It
did, down to 138 chart files and four scorecards.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="building-a-flag-that-isnt-lying-to-you">Building a flag that isn't lying to you<a href="https://optic-yellow.com/blog/the-draft-grades-itself#building-a-flag-that-isnt-lying-to-you" class="hash-link" aria-label="Direct link to Building a flag that isn't lying to you" title="Direct link to Building a flag that isn't lying to you" translate="no">​</a></h2>
<p>With the free player-finder settled and the tool consolidated, the
actual flag got built — the scoring formula above, trained on the
pipeline's self-reported effort and graded the
never-grades-its-own-homework way.</p>
<p>Two surprises turned up. Aces — serves the returner never touches —
were scored as if the machine had refused every shot in the point,
since there are no rally shots to refuse. Backwards: an ace is the
cleanest point in the match, punished for being short. Fixed by
teaching the formula that no shots after the serve means the point
ended cleanly.</p>
<p>Stranger: on the Roland-Garros match, the broadcast editor sometimes
cuts into a rally already in progress. The serve-detector, seeing the
ball cross the net for the first time in the clip, calls that crossing
"the serve" — even though a real serve toss lands 20+ meters past the
baseline, and this one launched from inside the court. That's a clip
that joined mid-rally, not a serve. Teaching the formula that one tell
took held-out reliability from 65% to 84%.</p>
<p>One more result belongs here because it failed: a stricter flag —
"sign off without watching the point at all," 85% accurate or better.
Tested the same honest way, it landed at 50% precision, a coin flip,
willing to call only 1.5% of points that confident. There aren't
enough graded points to support it: of 135 hand-charted points, only
about 1 in 9 already lands within two corrections. That tier is dead,
reported dead rather than dropped quietly — a confidence system that
hides its own failures isn't one anyone should trust.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-scorecard">The scorecard<a href="https://optic-yellow.com/blog/the-draft-grades-itself#the-scorecard" class="hash-link" aria-label="Direct link to The scorecard" title="Direct link to The scorecard" translate="no">​</a></h2>
<p>What did the shipped flag earn, tested the honest way? Read the pooled
number the way you'd read a strong first-serve percentage — not a
guarantee, but a number worth building on:</p>
<table><thead><tr><th>match</th><th>HIGH flag right, held out</th><th>how often HIGH gets called</th></tr></thead><tbody><tr><td>the night match</td><td>11 of 11 (100%)</td><td>about half the points</td></tr><tr><td>the control match</td><td>3 of 3 (100%)</td><td>about 3 in 5 points</td></tr><tr><td>the Roland-Garros match</td><td>16 of 19 (84%)</td><td>about 1 in 3 points</td></tr><tr><td>the Wimbledon match</td><td>11 of 11 (100%)</td><td>about 1 in 5 points</td></tr><tr><td>all four matches together</td><td>41 of 44 (93%)</td><td>about 1 in 3 points</td></tr></tbody></table>
<p>HIGH means "start from this draft" — 93% of the time, held out, it
needs five corrections or fewer. It doesn't mean the draft is already
right: only about a quarter of HIGH points need two corrections or
fewer. Missing HIGH means "expect heavy correction" — more than half
of those need six or more, so treat it as a hint, not a finished
chart. The Roland-Garros match stays the shakiest, not because its
math is worse — its disease is missing footage, shots the editor cut
before the camera ever saw them, which no signal can detect from film
that doesn't exist.</p>
<p>Said the way a tennis fan would at dinner: a confidence flag only
means something once it's been tested on points it never got to see
the answers to first.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><p><strong>Shot-call sink.</strong> Ball-x vs. box-center-x at contact, gated on the
ball reaching the box; biggest post-direction edit sink, 1.71
edits/point. Audit (178 aligned letters, 4 matches, pre-fix):</p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">outcome         sane   implaus  absent</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">right             70      31       0</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">wrong             17      18       0</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">refused           11      23       8</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">accuracy          71%     39%      --</span><br></span></code></pre></div></div><p>45% of letters read off a bad box; ceiling ~+26 strict letters of 209
scored.</p><p><strong>Free hygiene</strong> (tuned on t3, t1/t2/t4 held out): court-half
plausibility, x-only teleport rejection, gap interpolation, letter
gate widened to clip-typical body height. Net: 114/209 → 117/209.
Three other fixes were built, measured, and removed (not detailed
further here).</p><p><strong>SAM-3 buy test.</strong> t3 = Roland Garros 2023 final (Djokovic–Ruud,
clay), 59 clips; prompts auto-derived from the bgsub boxes, ~$12
all-in. Same eval, same hygiene:</p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">t3 letters              bgsub+hygiene   SAM-3</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">strict positional        67/114 (59%)   76/114 (67%)</span><br></span></code></pre></div></div><p>21 gains, 15 of them refusals turned right (t3_point_25 alone +7).
Acceptance stays 3/59. Default stays bgsub at $0.</p><p><strong>Consolidation.</strong> <code>courtvision/</code> package: one chart assembler, one
eval, one CLI; per-broadcaster differences moved into
<code>data/matches/&lt;id&gt;.yaml</code>. Gate: 138 chart CSVs byte-for-byte, 4
scorecards byte-identical, acceptance 7/135 untouched. <code>experiments/</code>
frozen as history. One command: <code>courtvision draft t3</code> → chart CSVs →
confidence per point → <code>outputs/t3/export/t3_mcp_draft.csv</code>.</p><p><strong>Confidence layer.</strong> Per-point signals — serve commit and stance
margin, striker-chain conflicts, ball coverage and holes, refusal
fractions, the direction signal-tier ladder, crossings-vs-shots
consistency — feed a small logistic regression (numpy), calibrated
leave-one-match-out (LOMO) against token edit distance: fit on 3
matches, score the 4th, rotate.</p><p>Aces bug: a 100%-refusal charge for zero rally letters to refuse.
Mid-rally-cut signature named <code>t3</code>/clay in the codebase.</p><p>Failed tier target: ≤2 token edits at ≥85% precision; exact base rate
11.1% (≤2-edit) of the 135 calibration points.</p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">LOMO (held out)   high prec (≤5 edits)   coverage   low ≤5 rate</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">t1 night              11/11 (100%)         50.0%        27%</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">t2 ctrl                 3/3 (100%)         60.0%        50%</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">t3 clay               16/19  (84%)         32.2%        43%</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">t4 grass              11/11 (100%)         22.4%        50%</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">pooled                41/44  (93%)         32.6%        44%</span><br></span></code></pre></div></div><p><strong>Export.</strong> One CSV, Match Charting Project points schema (match,
point number, score state, server), machine string in the 1st-serve
column, plus <code>confidence</code>, <code>conf_p</code>, clip name, jump-to timestamp
(<code>serve_s</code>). Across 4 matches: 138 draft points, 46 flagged high.</p><p><strong>Precisely stated promise:</strong> given a match with a fitted homography
and transcribed score-bug alignment, a draft for every tracked point
at $0 marginal cost, HIGH on ~1/3 at 93% held-out reliability. Not
promised: a HIGH point is right, a LOW point is chartable without
video, or anything about rallies cut before broadcast. North star:
acceptance ≤1 token edit = 5.2% (7/135), ≤5-edit curve = 57.0%.</p></div></div></details>
<p>None of this moves the project's real ceiling, unchanged since cv-14:
only 5.2% of drafted points — about 1 in 20 — are an exact-or-one-fix
match to a human chart, and even counting anything within five
corrections as close enough, that's still just 57%. The ceiling isn't
stuck on any piece of the pipeline anymore — it's stuck on the answer
key, only 135 hand-charted points, barely 1 in 9 of them already
within two corrections. More matches are already being lined up, and
the moment they land, every number here gets recalculated against a
bigger, harder answer key.</p>
<p>Session cost: about $12 — the SAM test, under a one-time $30 limit —
then $0.00 for everything built after it. Project total: about $16.</p>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
        <item>
            <title><![CDATA[Within One Token of a Human]]></title>
            <link>https://optic-yellow.com/blog/within-one-token-of-a-human</link>
            <guid>https://optic-yellow.com/blog/within-one-token-of-a-human</guid>
            <pubDate>Fri, 10 Jul 2026 01:45:00 GMT</pubDate>
            <description><![CDATA[The project finally gets an honest report card: how many fixes would turn the machine's chart into a human's? Today's first answer: 2.2%.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top: an ad-point rally from the Djokovic–Ruud Roland Garros
final, just under six seconds of video, with the machine's chart building
itself token by token above the line a human volunteer wrote for the same
point. Four pieces light up green, one after another, because the machine
and the human agree on every one of them: a second serve landing in the
body of the box, then three backhands in a row — two to the same spot,
then one that breaks the other way. Then the last ball dies in the net.
The human's shorthand for that is the letter <code>n</code>; the machine's chart
shows a different mark instead, <code>*</code>, so that last piece glows amber — one
disagreement. Under the rule this post is about, one disagreement is
still close enough to count: the point is <strong>accepted</strong>. The video's
closing screen turns the whole story into four bars.</em></p>
<p>Here's the question this post is finally built to answer: if you handed
the machine's chart of a point to the volunteer who charted that same
point by hand, how many things would she have to fix before she'd sign
her name to it?</p>
<p>Previously: last time (<a class="" href="https://optic-yellow.com/blog/the-score-bug-is-the-point-id">cv-13</a>), the
software learned to read the small scoreboard graphic in the corner of
the screen. That graphic stays pixel-for-pixel identical for the whole
point and changes only the instant a point ends, so reading it let the
pipeline do two things: glue video clips back together into whole points,
and look up the exact matching point in a human volunteer's hand-charted
record of the same match — the <a class="" href="https://optic-yellow.com/primer#ground-truth">ground truth</a>
this whole project has been grading itself against since
<a class="" href="https://optic-yellow.com/blog/t1-answers-in-the-back">cv-10</a>. That match-up is what makes this
post possible: for the first time, every point the machine drafts can be
checked, one for one, against what a real person wrote down for that
exact point. Before today, the software also had a specific way of
finding hits and bounces — watching for the moment the ball's path bends
backward on itself, <a class="" href="https://optic-yellow.com/primer#hits-and-bounces">the court doesn't return
serve</a>, from
<a class="" href="https://optic-yellow.com/blog/m2-hits-and-bounces">cv-04</a> — a rule that mostly worked but
quietly missed shots hit far from the camera.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="how-do-you-grade-a-chart">How do you grade a chart?<a href="https://optic-yellow.com/blog/within-one-token-of-a-human#how-do-you-grade-a-chart" class="hash-link" aria-label="Direct link to How do you grade a chart?" title="Direct link to How do you grade a chart?" translate="no">​</a></h2>
<p>Think about how a coach would grade a rookie scout's notes on a match.
The scout writes down what happened, point by point, in shorthand. The
coach already has her own notes on the same match. She doesn't grade
shot by shot — she reads the scout's line for a point next to her own
line for that same point and asks one blunt question: how many words
would I have to cross out, change, or add to turn the scout's version
into mine?</p>
<p>That's exactly what this post's headline number measures, except the
"shorthand" is real tennis charting notation and the "coach" is a
volunteer from the Match Charting Project — the same <a class="" href="https://optic-yellow.com/primer#ground-truth">ground
truth</a> this project has been grading itself
against. Write a chart of one point as a list of pieces, or tokens: the
serve and where it landed, then each shot's stroke and direction, then
how the point ended. Do that for the machine's version and the human's
version of the same point, then count how many of those pieces would
need to be changed, added, or removed to turn one list into the other.
That count is called <strong><a class="" href="https://optic-yellow.com/primer#token-edit-distance">token edit distance</a></strong>,
and it's now the north-star number for the whole project — the honest
answer to "would a human sign off on this."</p>
<p>A point counts as <strong>accepted</strong> when that count is one or less — the kind
of chart a charter would wave through after fixing at most one thing.
And there's a rule that keeps the machine honest: if it isn't sure and
writes a "no idea" placeholder instead of guessing, that placeholder
still has to be fixed later, so it costs exactly as much as getting the
answer wrong. Shrugging is not a way to dodge a mistake.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="rebuilding-how-hits-get-found">Rebuilding how hits get found<a href="https://optic-yellow.com/blog/within-one-token-of-a-human#rebuilding-how-hits-get-found" class="hash-link" aria-label="Direct link to Rebuilding how hits get found" title="Direct link to Rebuilding how hits get found" translate="no">​</a></h2>
<p>Before any of that grading could happen, the event detector needed work.
The old approach hunted for a kink in the ball's flight path — the
moment its direction snaps backward, which is <a class="" href="https://optic-yellow.com/primer#hits-and-bounces">how a hit differs from a
bounce</a> — but a kink from a shot on the far
side of the court, seen from a raised broadcast camera, can shrink to a
single wobbling pixel that's easy to miss entirely. So the detector got
rebuilt around a blunter, more reliable piece of tennis sense: a rally in
progress sends the ball across the net every single shot. Instead of
squinting at every wobble, the software now counts how many times the
ball crosses the net and adds one for the serve — and that crude count
alone, before any fine-tuning at all, already beat the entire old
wobble-hunting detector at guessing how long a rally ran. The rebuilt
detector went on to sharply improve how often it read stroke letters
correctly, though one older test clip actually got worse in the process —
a small, honestly reported step backward, not swept under the rug
(exact numbers in the appendix).</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-first-reading-22">The first reading: 2.2%<a href="https://optic-yellow.com/blog/within-one-token-of-a-human#the-first-reading-22" class="hash-link" aria-label="Direct link to The first reading: 2.2%" title="Direct link to The first reading: 2.2%" translate="no">​</a></h2>
<p>With the rebuilt detector in place and every point matched to its human
original, the project could finally run the grading question for real —
across four matches, three court surfaces, both the men's and women's
tours: how many of 135 points would a volunteer charter sign off on with
at most one fix?</p>
<p>The answer: <strong>3 points out of 135 — 2.2%.</strong> Before the detector rebuild,
using the old event logic, that number was zero. Two percent isn't a
good score. But it's the <em>first honest</em> score — a real distance to the
top of the mountain, measured in a unit a volunteer charter actually
cares about, instead of a pile of separate stats that could all be
quietly improving while the finished chart is still wrong.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="where-the-fixes-were-coming-from">Where the fixes were coming from<a href="https://optic-yellow.com/blog/within-one-token-of-a-human#where-the-fixes-were-coming-from" class="hash-link" aria-label="Direct link to Where the fixes were coming from" title="Direct link to Where the fixes were coming from" translate="no">​</a></h2>
<p>A 2.2% pass rate means the typical point needed about seven fixes, on
average, to become the human's version — so the project broke that
average down by fix type to see where the effort was going. Missing
shots — a shot the human charted that the machine's chart skipped
entirely — were the single biggest source. But close behind, and the
most fixable-looking, was shot <strong>direction</strong>: which way a shot traveled,
not just what stroke it was. Getting the direction digit wrong, whether
alone or combined with the wrong stroke letter, accounted for 39% of
every fix an editor would have to make — the largest chunk of the whole
edit budget. And the direction guesses weren't just wrong often: the
machine only ventured a direction guess on 70% of shots, and when it
did, it was right just 48% of the time — barely above the 33% you'd
expect from picking one of three directions at random.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-compass-pointed-the-wrong-way">A compass pointed the wrong way<a href="https://optic-yellow.com/blog/within-one-token-of-a-human#a-compass-pointed-the-wrong-way" class="hash-link" aria-label="Direct link to A compass pointed the wrong way" title="Direct link to A compass pointed the wrong way" translate="no">​</a></h2>
<p>That's a big enough number to be worth chasing, and the cause turned out
to have nothing to do with cameras or vision at all — it was a
definition problem. The Match Charting Project's own instructions define
one shot direction as "a right-hander's forehand side" and add, in
parentheses, "a lefty's backhand." Read carelessly, that sounds like
direction depends on which hand hit the ball. It doesn't: the
parenthetical is the tell. Direction is defined relative to a fixed side
of the court — specifically, the half that's <em>receiving</em> the shot — and
it only happens to line up with "forehand side" for one particular kind
of player. The pipeline's code, though, had been computing direction
using fixed court thirds, as if the same physical side of the court
always meant the same direction: correct by pure coincidence for a shot
going to the far end, backwards for a shot going to the near end. It was
a compass mirrored for half the court the whole time. Once the
definition got fixed — direction read relative to which half was
receiving, instead of a fixed physical side — direction accuracy jumped
from 48% to 75%, the machine started venturing a guess on 97% of shots
instead of 70%, and overall acceptance nearly doubled: from 3 points out
of 135 to 7, from 2.2% to 5.2%.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-scoreboard">The scoreboard<a href="https://optic-yellow.com/blog/within-one-token-of-a-human#the-scoreboard" class="hash-link" aria-label="Direct link to The scoreboard" title="Direct link to The scoreboard" translate="no">​</a></h2>
<p>Four matches, three surfaces, both tours — here's how many of each
match's points a human charter would sign off on with at most one fix,
after the compass fix went in:</p>
<table><thead><tr><th>Match</th><th>Points compared</th><th>Accepted (≤1 fix)</th></tr></thead><tbody><tr><td>Night match</td><td>22</td><td>3</td></tr><tr><td>Control match</td><td>5</td><td>1</td></tr><tr><td>Clay — Roland Garros final</td><td>59</td><td>3</td></tr><tr><td>Grass — Wimbledon final</td><td>49</td><td>0</td></tr><tr><td><strong>All four, combined</strong></td><td><strong>135</strong></td><td><strong>7 (5.2%)</strong></td></tr></tbody></table>
<p>That's up from 3 of 135 (2.2%) before the direction fix. The honest low
point on the board is Wimbledon: zero points accepted, because its
problem isn't a wrong direction digit — it's shots that shouldn't be in
the chart at all, or shots missing from it (more in the appendix).</p>
<p>The takeaway a tennis fan could repeat at dinner: what matters isn't how
many little stats are climbing, it's how many marks a red pen still
needs — and fixing one wrong assumption about which way "direction"
points nearly doubled how many points needed no more than one.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><p><strong>Net-crossing detector rebuild.</strong> Two passes. The serve detector went
ball-first — the serve is the track's first sustained net crossing, its
direction names the server's end — and clay server-end accuracy went
7/58 to 48/59 (12% → 81%), grass 17/49 to 38/49, with the honest asterisk
that grass refuted the ball signal outright (white ball, white lines:
26/49, a coin flip), so the grass detector kept the player-based signal
and only the search window learned to slide. Then the coda pass went
hunting for a per-event tell that separates a live rally from
Wimbledon's dead-ball aftermath, and refuted every candidate — landing
positions, walking speed, coverage, all byte-similar between the two —
until the rule that held was sequence-level tennis: a live rally sends
the ball across the net every shot.</p><p>That sentence turned out not to be a truncation rule; it was the
detector, inverted. The old design — M2-era cusp classification (the
wobble-by-wobble hit/bounce guesser from cv-04), tuned on sparse SAM
tracks (the ball-tracking AI's own thin, gap-ridden paths) — inspected
every wobble in the ball track and asked "hit or bounce?" Diagnosing all
57 rally-count misses (not a sample) found the far-end miss systematic:
a far hit at 250+ px is a ~1 px/frame wiggle no near-tuned gate can feel,
but its flight crosses the net at full projected speed. The keystone
measurement, taken before any design was written: raw net-crossing count</p><ul>
<li class="">1 already beat the entire old detector — rally ±1 of 36/59 vs the
chart's 25/59 on clay, 36/49 vs 26/49 on grass, from just counting
crossings.</li>
</ul><p>So v5 stops classifying wobbles. Net crossings are the rally skeleton:
they partition the point, each partition contains exactly one hit, and
the cusp machinery is demoted from counting shots to locating the hit
inside its slot. Constants tuned on t3 (the clay match) only, everything
else scored untouched — the tuned tree went 42% → 61% on rally length
while letters (aligned) tripled its base, 19/24 → 65/85. The named
regression: t1 (the night match) fell 16/22 → 13/22. The skeleton needs
crossings, and the night reel's thin low-recall tracks amputate rallies
the old cusp counter used to pad out. A design that leans on the net
pays the price wherever the track can't see the ball cross it.</p><p><strong>Edit-distance mechanics.</strong> Tokenize both strings as
<code>[serve+zone][letter+direction]*[ending]</code> and accept a point iff the
machine's list is within one token edit of the human's — strict
equality, a <code>?</code> matches nothing, so refusing to commit costs exactly
what being wrong costs. First reading, all four matches: 3/135, 2.2%
(before v5 it was 0/135). Mean token edits per point: 7.18 across n=135,
backtraced to an alignment and binned:</p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">edit category (mean/point, n=135)      overall</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">deletion (shot never charted)          1.95</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">sub: letter AND direction wrong        1.61</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">sub: direction digit only              1.20</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">sub: ending token                      0.70</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">insertion (phantom shot)               0.54</span><br></span></code></pre></div></div><p>Direction digits sit in two of those bins and add up to 2.81 edits per
point — 39% of the entire edit budget — attempted on only 70% of shots
and 48% right when attempted, barely above the 33% floor. Meanwhile the
effort curve says the skeleton v5 just rebuilt is no longer the wall:
structure alone (shot count + letters) would already accept 23.7% of
points at the same ≤1 bar, per the original decomposition note (this
figure is carried over as originally reported alongside the ≤3-edit
curve below; the two are not obviously the same slice of the data, and
that ambiguity is preserved rather than resolved here). The headroom
table, fixing one component to ground truth at a time:</p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">counterfactual                        accept ≤1      mean dist</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">baseline (v5 as charted)              3/135 ( 2.2%)  7.18</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">directions perfect (all shots)        9/135 ( 6.7%)  5.88</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">endings perfect                       7/135 ( 5.2%)  6.47</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">letters + directions                 21/135 (15.6%)  3.93</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">letters + dirs + endings             53/135 (39.3%)  3.23</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">all components (structure residual)  59/135 (43.7%)  2.93</span><br></span></code></pre></div></div><p>No single fix rescues the metric — the mean point is wrong on several
axes at once — but the compounding is steep and ordered, directions are
the biggest single lever, and the tell is in the fine print: perfecting
only the <em>attempted</em> directions is worth almost nothing. The sparsity
and the accuracy are the same disease.</p><p><strong>The mirrored compass.</strong> MCP's instructions define direction 1 as "a
right-hander's forehand side (a lefty's backhand)" — the parenthetical
is the tell. That sentence names a fixed side of the receiving half,
mirrored by which end is receiving, not flipped by who's holding the
racquet. <code>zone()</code> had been coding absolute court thirds: correct for a
far right-handed receiver by coincidence, mirrored for every near
receiver. Half of "barely above the random floor" was a sign error.
Every plausible mapping went into a calibration table before any code,
and the both-lefty t1 match is what adjudicated the handedness question:
receiver-end mirror without a handedness flip scores 11/24 on the
lefties; adding the flip drops it to 5/24 — the naive reading of the
spec loses to its own parenthetical.</p><p>On top of the fixed compass, a measured signal ladder gives every shot a
direction now — the workhorse is the receiver's next contact (where the
ball was received is where this shot went; 77% right, 81% coverage on
the dev match), backed by the near-half bounce (86%) and the v5 skeleton
moonlighting as a crossing-plus-slope extrapolator. Even refusal policy
was an experiment, not a principle: vetoing on disagreement kept
precision flat while shedding net-right tokens, and since the metric
prices a shrug the same as an error, commit-always won.</p><p>Direction attempts went 70% → 97%, accuracy when attempted 48% → 75% —
held-out matches gaining right alongside the tuned one — and acceptance
moved 3/135 → 7/135, with the ≤5-edit curve jumping 41.5% → 57.0%. Every
other metric on all four scorecards is byte-identical, exactly the claim
a direction-only change should be able to make. The meta-lesson joins
the permanent file: when a component scores near the random floor, check
the semantics before rebuilding the perception. A 27-point accuracy jump
was sitting in a coordinate convention the whole time.</p><p><strong>Full scorecard.</strong> Four matches, three surfaces, both tours, 168 points
aligned to human charts as originally reported for this section (a
different count from the n=135 used above for the token-edit
decomposition — both numbers are carried over as originally stated):</p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">metric              t1 night   t2 ctrl   t3 clay      t4 grass</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">server end          10/22      2/5       48/59 (81%)  37/49 (76%)</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">rally length ±1     13/22      5/5       36/59 (61%)  28/49 (57%)</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">serve zone          11/12      1/3        8/17        15/32</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">letters (aligned)    9/11      12/12     65/85 (76%)  15/28</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">ending type          9/16      2/3       19/42         9/33</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">acceptance ≤1 edit   3/22      1/5        3/59         0/49</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">acceptance at ≤ k token edits (n=135):</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">≤1: 2.2 → 5.2%    ≤2: 6.7 → 11.1%    ≤3: 14.8 → 23.7%    ≤5: 41.5 → 57.0%</span><br></span></code></pre></div></div><p>The honest reads stay on the record: t4 (grass, Wimbledon) is 0/49
strict — its disease is structure (1.24 phantom insertions plus
deletions per point), and no direction-digit fix repairs a token that
shouldn't exist in the first place. t3's (clay, Roland Garros) missing
shots are mostly the broadcast's fault, not the pipeline's — the RG
editor cuts into rallies mid-point — and structure caps the
all-components ceiling at 44%. The re-run decomposition already names
the next levers in order: structure, letters, endings — one token per
point, 30% right, as originally reported.</p></div></div></details>
<p>Two percent was the first honest look at how far this project has to
climb. Five percent, after fixing the compass, is the second look. The
real change isn't the number itself — it's that the climb now has
arithmetic behind it instead of a guess.</p>
<p>Session cost: $0.00. Project total so far: about $4.15 of a $9 budget.</p>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
        <item>
            <title><![CDATA[The Score Bug Is the Point ID]]></title>
            <link>https://optic-yellow.com/blog/the-score-bug-is-the-point-id</link>
            <guid>https://optic-yellow.com/blog/the-score-bug-is-the-point-id</guid>
            <pubDate>Fri, 10 Jul 2026 01:30:00 GMT</pubDate>
            <description><![CDATA[A TV scoreboard graphic nobody notices turns out to be the only reliable way to tell where one point ends and the next begins.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top: a 21-second, sixteen-shot Djokovic–Ruud rally from
the Roland Garros final. The old segment-finder had chopped it into
four broken pieces — the tick marks on the timeline are where those
old cuts fell, and each piece charted only 1 to 3 shots alone. This
post's method proved all four belong to one point, glued them back
together, and the same shot-detector read the reunited rally as 12 of
16 shots correctly.</em></p>
<p>Every TV director cuts away sometimes — a reaction shot, a replay, a
different angle. So how do you prove, from footage alone, that two
clips are really the same point? Two test matches weren't enough, so
this session added harder ones: the 2023 Roland Garros final on clay,
and the 2024 Wimbledon final on grass. Four matches now, three
surfaces, both tours, about 108 new points checked against a human's
chart, for zero dollars. What broke wasn't one narrow piece — it broke
the assumption under every number on the board, and the fix had been
on screen all along.</p>
<p>Previously: <a class="" href="https://optic-yellow.com/blog/t1-answers-in-the-back">cv-10</a> gave the project its
first honest report card, grading its draft charts against a match a
human had already charted by hand. <a class="" href="https://optic-yellow.com/blog/the-zero-dollar-specialist">cv-12</a>
then swapped in cheaper, more reliable ball-tracking software — and
uncovered an accident: the old tracker's habit of <em>losing</em> the ball
when a rally ended had secretly been flagging "this point is over."
The new tracker never loses the ball, so that free signal vanished,
leaving the project needing a real point-boundary detector.
<a class="" href="https://optic-yellow.com/blog/m3-the-cutting-room">cv-06</a> had already learned a broadcast is
mostly not rally footage — finding the stretches that even show the
court took its own detective work.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-one-graphic-that-never-lies">The one graphic that never lies<a href="https://optic-yellow.com/blog/the-score-bug-is-the-point-id#the-one-graphic-that-never-lies" class="hash-link" aria-label="Direct link to The one graphic that never lies" title="Direct link to The one graphic that never lies" translate="no">​</a></h2>
<p>Picture a broadcast with the sound off. Almost everything keeps
changing — players move, the camera pans, the crowd shifts — except
the scoreboard graphic in the corner. It isn't part of the court; it's
stamped on top of the picture like a watermark, so a pan can't nudge
it. And it obeys a rule nothing else on screen does: frozen, pixel for
pixel, for the whole length of a point, changing at exactly one
moment — when the point ends.</p>
<p>That discipline is what this series calls <strong>the score bug as
metadata</strong> (<a class="" href="https://optic-yellow.com/primer#score-bug">more in the primer</a>): the graphic,
just by sitting there frozen, quietly carries the match's structure.
Two things fall out of it for free. It's a point ID — two chunks of
video showing the identical frozen scoreboard are fragments of the
same point, however the editor cut between them, and can be safely
stitched together. And it's a join key — a shared label matching this
clip to its point in the human charter's own record.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="clay-and-grass-fight-back">Clay and grass fight back<a href="https://optic-yellow.com/blog/the-score-bug-is-the-point-id#clay-and-grass-fight-back" class="hash-link" aria-label="Direct link to Clay and grass fight back" title="Direct link to Clay and grass fight back" translate="no">​</a></h2>
<p>The clay final (Djokovic vs. Ruud) broke the court-mapping recipe that
worked fine on hard courts. Turning the TV picture into a court
diagram needs the <a class="" href="https://optic-yellow.com/primer#homography">homography</a> — the
screen-to-court conversion recipe — and its usual tricks assume a blue
hard court with crisp white lines. Clay has neither: the "find the
blue court" trick found nothing, and the trick that erases players by
blending frames together smeared the faint lines into the dirt
instead. A clay-specific rebuild (appendix has the method) got it
working again, accurate to a fraction of a pixel.</p>
<p>The grass final (Krejcikova vs. Paolini) broke the <em>check</em> on the
recipe instead: the software found a court mapping whose lines lined
up neatly with each other — and it was wrong, like an umpire
confidently announcing a plausible score that isn't the real one.
That's an impostor fit. The fix: instead of "do the few lines this
mapping used line up?", ask "do <em>all nine</em> of the court's real lines
land where this mapping says they should?" A wrong mapping explains a
few lines and leaves the rest floating; the real one explains all
nine. (One more check earned its keep on this match too, following
<a class="" href="https://optic-yellow.com/blog/t1-right-hardware-wrong-sign">cv-11</a> — the appendix has it.)</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-segment-is-not-a-point">A "segment" is not a "point"<a href="https://optic-yellow.com/blog/the-score-bug-is-the-point-id#a-segment-is-not-a-point" class="hash-link" aria-label="Direct link to A &quot;segment&quot; is not a &quot;point&quot;" title="Direct link to A &quot;segment&quot; is not a &quot;point&quot;" translate="no">​</a></h2>
<p>Both matches mapped, the draft charts still came back mediocre — for a
reason underneath every number, not inside any one of them. The
pipeline assumed one unbroken stretch of court-view video (a
<em>segment</em>) equals one point. That breaks in opposite directions,
depending on who's holding the editing scissors.</p>
<p>Roland Garros cuts away mid-rally, chopping one point across several
segments: the draft charted just 3.9 shots per point here against the
human charter's real 8.9. The rally was there; the video just told it
in pieces. Wimbledon does the opposite, almost never cutting between
points, so one fifteen-second stretch can hold two or three back to
back — the draft overcounted there, 8.1 shots per point against the
real 7.2, stringing separate points into one fake rally.</p>
<p>The two earlier test matches had simply gotten lucky: their
broadcaster cut almost exactly once per point, splitting the match for
free — the same kind of accident as the old tracker's habit of losing
the ball, doing a job nobody built it to do.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="reading-the-scoreboard-settles-it">Reading the scoreboard settles it<a href="https://optic-yellow.com/blog/the-score-bug-is-the-point-id#reading-the-scoreboard-settles-it" class="hash-link" aria-label="Direct link to Reading the scoreboard settles it" title="Direct link to Reading the scoreboard settles it" translate="no">​</a></h2>
<p>This is the gap the score bug closes. Two chunks of video with the
identical frozen scoreboard, however the editor cut between them, are
the same point — merge them. One chunk where it changes partway
through is secretly two points — split it there. Read the sequence of
changes and you get the real point-by-point structure, regardless of
where the editor cut. It held with a wide, safe margin between "same
point" and "different point" readings, and a handful of broadcaster
quirks that tried to break it — a scoreboard that grows after every
set, a tiebreak banner that shoves it sideways, a fade-in that could
fake a change that never happened — all got caught by looking closely
at the pixels (appendix has the numbers).</p>
<p>One theory got tested and killed outright, worth saying since it
contradicts what I'd guessed going in: I suspected slow-motion replays
were sneaking duplicate points into the count. They aren't. Every
suspicious duplicate score turned out to be fragments of one rally, or
a genuinely different point that passed through the same score — deuce
recurs, and the scoreboard alone can't tell those visits apart, only
their <em>order</em> can. The last suspect was cleared by physics: the ball's
own falling motion matched real-time gravity, and a slow-motion replay
would fall roughly four times slower.</p>
<p>With segments stitched into points correctly, the clips lined up
against the human record almost perfectly.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-scorecard">The scorecard<a href="https://optic-yellow.com/blog/the-score-bug-is-the-point-id#the-scorecard" class="hash-link" aria-label="Direct link to The scorecard" title="Direct link to The scorecard" translate="no">​</a></h2>
<p>Matched row for row against the <a class="" href="https://optic-yellow.com/primer#ground-truth">human-charted</a>
record — the independent answer key this project grades itself
against:</p>
<table><thead><tr><th>What was measured</th><th>Before</th><th>After</th></tr></thead><tbody><tr><td>Clips matched to their correct point, clay</td><td>59 of 68</td><td>59 of 59 — every one</td></tr><tr><td>Clips matched to their correct point, grass</td><td>36 of 48</td><td>49 of 49 — every one</td></tr><tr><td>Rally length guessed within one shot, clay</td><td>34%</td><td>41%</td></tr><tr><td>Rally length guessed within one shot, grass</td><td>15 of 36 (about 42%)</td><td>21 of 49 (about 43%)</td></tr><tr><td>Serve zone guessed correctly, grass</td><td>1 of 4</td><td>8 of 10</td></tr><tr><td>Which player served, clay (honest step back)</td><td>10 of 59</td><td>7 of 58</td></tr><tr><td>How the point ended, clay (honest step back)</td><td>11 of 34</td><td>9 of 30</td></tr></tbody></table>
<p>The two rows that went down are real, worth naming rather than hiding:
merged clips can start mid-rally, so the software falls back to a
weaker clue (simple alternation) for who served and how it ended. A
fair trade for getting the boundaries right, but a real cost. Where
the merges had footage to work with, the payoff showed: the 16-shot
rally up top went from four broken pieces to one point charted 12 of
16 correctly, and another clay point came back a perfect 12 of 12.</p>
<p>Two problems stay open, neither a scoreboard problem anymore. On clay,
the editor sometimes cuts into a rally already in progress, so some
early shots were never filmed — the scoreboard proves the clip is one
point, but can't invent missing footage. On grass, 19 clips still
count as two points when they're really one, because Wimbledon's dead
time sits <em>inside</em> that same frozen reading (mechanism in the
appendix). Both are chart-reading problems now, not video-editing
ones — and they're next.</p>
<p>The least glamorous graphic on the screen — a tiny scoreboard nobody
watches on purpose — is the one witness every broadcaster in the world
agrees to keep rolling at all times, which makes it the most
trustworthy thing in the picture.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><p><strong>t3, clay</strong> (Djokovic–Ruud, RG world feed, 34 min highlights): M1
homography died three ways — "blue court hull" has no blue, and the
clay-hue mask swallows the frame (crowd skin ≈ clay hue); median plate
smears lines into dirt (<del>16 px pan, white-on-orange loses to
white-on-blue); far baseline buried (V</del>200 on V~195 clay). Rebuild:
ECC-stabilized plates, thin-bright tophat filter, verticals labeled by
symmetry (courtside Perrier crates give 20 Hough segments inside the
hull; junk is asymmetric, lines aren't), fit basis swapping the buried
baseline for the far service line. Result: sidelines 0.4/0.5 px,
center line 0.2 px.</p><p><strong>t4, grass/WTA</strong> (Krejcikova–Paolini, Wimbledon feed): a
wrong-but-self-consistent line assignment fit its own intersections to
rms 1.7 px. Fix: score candidates by how well all nine model lines
land on the observed mask — impostors strand the lines they didn't
use.</p><p><strong>Handedness</strong>: MCP's metadata listed Vondrousova, a well-known
left-hander, as right-handed. Post-cv-11 policy: never trust the
chart's "hand" column, look it up independently.</p><p><strong>Segment note</strong>: some RG fragments in the 3.9-vs-8.9 split were
written off by the staging pass as replays.</p><p><strong>Score bug validation</strong>: same-point crops differ by ≤0.005
changed-pixel fraction, different-score crops by ≥0.033 — a 6x margin.
Traps: the RG bug grows a column per set (digits slide ~27 px right);
the set-1 tiebreak banner drops it 14 px and drifts; a reference frozen
on a fade-in frame lets compression noise fake a change 100 frames
later. Fix: frozen reference per plateau, anchored on the median of a
settling window.</p><p><strong>Replay hypothesis, refuted</strong>: every duplicate-score group was
fragments of one rally or distinct deuce points (a 40-40 plateau sits
between "duplicate" 40-AD clips: three deuce cycles; order carries
what value alone can't). Cadence suspect acquitted: WASB-track ball
gravity is unimodal across all 116 clips; 2x slow-mo would sit ~4x
low.</p><p><strong>Remaining gaps, mechanism</strong>: t4's 19 over-counted clips fail because
Wimbledon's dead time lives inside the point's own plateau, the dead
ball stays inside the court-y envelope, and the freeze-#3 gate can't
touch it — the t1 dead-ball coda in a new costume.</p></div></div></details>
<p>Session cost: $0.00. Project total: about $4.15 of a $9 budget. The
benchmark is now four matches, three surfaces, both tours — and every
point ID in it comes from the one witness every broadcaster agrees to
keep on screen.</p>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
        <item>
            <title><![CDATA[The $0 Specialist and the Load-Bearing Bug]]></title>
            <link>https://optic-yellow.com/blog/the-zero-dollar-specialist</link>
            <guid>https://optic-yellow.com/blog/the-zero-dollar-specialist</guid>
            <pubDate>Fri, 10 Jul 2026 01:15:00 GMT</pubDate>
            <description><![CDATA[A free specialist replaces an expensive all-purpose tracker — and exposes that its rival's biggest flaw was secretly marking the end of every point.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top: the same Federer rally, tracked two different ways.
Left, the old general-purpose tracker's trail of yellow dots — a
comet's tail — thins out and dies partway through; it wrote down only
six of twelve shots. Right, a tiny tennis-only specialist's trail stays
solid to the last shot. Same clip, same notation software underneath
both — the only thing that changed is who's watching the ball. The
specialist charted all twelve shots, and got every one of the seven
shot symbols it committed to right.</em></p>
<p>Before spending another dollar chasing a tennis ball across the screen,
I stopped and asked a simpler question: has somebody already solved
this?</p>
<p>Here's where things stood. The pipeline had just been graded for the
first time against a real match a human had already hand-charted — an
<a class="" href="https://optic-yellow.com/blog/t1-answers-in-the-back">out-of-sample test</a>, rules frozen before
anyone saw the answers, on a night match between Nadal and Shapovalov.
<a class="" href="https://optic-yellow.com/blog/t1-right-hardware-wrong-sign">That test</a> caught a mirror-image
bug — forehand and backhand swapped, because the software assumed a
right-handed player against two lefties — fixed with a tennis fact, not
new vision code. A second test was already promised: a sunlit day
match, Federer against Haase. Every bit of ball-tracking so far had
been done by SAM 3, a general-purpose "find that object" AI that needed
a box drawn around the ball by hand, and that charged a small fee per
clip.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-specialist-and-the-generalist">The specialist and the generalist<a href="https://optic-yellow.com/blog/the-zero-dollar-specialist#the-specialist-and-the-generalist" class="hash-link" aria-label="Direct link to The specialist and the generalist" title="Direct link to The specialist and the generalist" translate="no">​</a></h2>
<p>Picture two people you could hire to watch the ball for you. One is a
touring pro: capable of almost anything, but before every point you
have to walk over and tell them exactly what to watch. The other has
had one job their whole career — they've stood on the same line,
watching where the ball lands relative to it, thousands of times a day
— and never once needed a pep talk first.</p>
<p>SAM 3 is the touring pro. WASB, the program introduced in this post, is
the line judge: about the size of a phone photo, and it has only ever
looked at tennis balls. Show it a photograph and it lights up how
likely the ball is to be at each spot on screen, the way a heat map
does, and picks the brightest spot, fresh, thirty times a second, no
memory of the last one. That's <strong>a tiny specialist versus a huge,
promptable generalist</strong> — more at <a class="" href="https://optic-yellow.com/primer#wasb-vs-sam">Court Vision in Plain
English</a> — and it's what this post turns on. One
side effect matters later: with no memory, WASB can't <em>lose</em> the ball
the way SAM can. SAM carries the ball forward frame to frame —
<a class="" href="https://optic-yellow.com/primer#detection-vs-tracking">tracking, in the technical sense</a> — and
a tracker that loses its grip stays lost. WASB never had a grip to
lose.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="somebody-already-solved-this">Somebody already solved this<a href="https://optic-yellow.com/blog/the-zero-dollar-specialist#somebody-already-solved-this" class="hash-link" aria-label="Direct link to Somebody already solved this" title="Direct link to Somebody already solved this" translate="no">​</a></h2>
<p>An evening of careful research, checking every claim against more than
one source, settled two things. First: finding the ball in a tennis
broadcast is already a solved, free, open problem — a specialist almost
exactly like WASB is out there, pretrained and downloadable today. Our
own SAM-based tracking had been reinventing that wheel, worse, for a
small fee on every clip.</p>
<p>Second, and this matters more: every system like that stops <em>before</em>
the notation. None of them read a broadcast and write out who hit what,
where, in the shorthand a human charter would recognize. That gap — not
the pixels, the grammar — is the real target here. Somebody wants it
filled, too: the volunteer project supplying our human answer keys runs
today on just 32 unpaid, part-time people, and its founder has publicly
asked for more help.</p>
<p>One more gap we could close ourselves: nobody had published a
head-to-head test of a generalist against a specialist on a ball this
small and fast. So we ran one.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-happened-when-we-swapped-them-in">What happened when we swapped them in<a href="https://optic-yellow.com/blog/the-zero-dollar-specialist#what-happened-when-we-swapped-them-in" class="hash-link" aria-label="Direct link to What happened when we swapped them in" title="Direct link to What happened when we swapped them in" translate="no">​</a></h2>
<p>We wired the specialist's ready-made weights into the pipeline and left
the notation-writing software untouched, so any difference in the
output could only be the tracker's doing. It won almost everywhere it
mattered — numbers below. It also did something SAM structurally
couldn't: thirteen night-match clips never got a usable starting box for
SAM, so SAM tracked zero frames of them. WASB needs no starting box at
all — it tracked all thirteen anyway, 73–97% of frames each, no
clicking, no cost.</p>
<p>Then something broke.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-bug-that-was-secretly-the-whistle">The bug that was secretly the whistle<a href="https://optic-yellow.com/blog/the-zero-dollar-specialist#the-bug-that-was-secretly-the-whistle" class="hash-link" aria-label="Direct link to The bug that was secretly the whistle" title="Direct link to The bug that was secretly the whistle" translate="no">​</a></h2>
<p>On the night match, the number this project cares about most — shots
counted in a point, checked against the human's count — got <em>worse</em>
with the better tracker. Before the swap it had seven of eleven points
right; after, only five. Every miss counted too many shots, never too
few.</p>
<p>I went back to the photographs. Match point: the ball resting at the
frame's bottom edge, the crowd already on its feet,
mid-standing-ovation. A dead ball bouncing quietly in the corner while
both players stood around between points. A ball drifting through the
crowd above the back fence, long after the point ended. In every case,
the point was over. The ball-finder was not.</p>
<p>Here's the mechanism. SAM tracks — it locks onto one ball and remembers
it. When a rally ended and the ball sailed out of frame or vanished
into the crowd, SAM's memory broke: its trail of dots died, right
there, at the end of the point. Nobody designed that as a signal, but
the software downstream — the part that turns ball positions into a
chart — had quietly been leaning on it anyway, never told in so many
words when a point was over; it had just been reading SAM's dying trail
as the whistle. WASB never gets lost, because it never had hold of the
ball to begin with — it just keeps finding it, dead ball and standing
crowd and all, and the chart-writer kept faithfully logging "shots" out
of a point already finished. A bug had secretly been doing somebody
else's job.</p>
<p>The first fix I tried didn't work: a ball still in play after a real
hit moves fast, a dead ball bouncing after a point should be slow, and
that gap looked clean — until the day match's real serves turned out
just as fast as the fake bounces, and the line I'd drawn cut through
real shots too.</p>
<p>What worked was tennis, not more tuning: a ball still part of the point
never leaves the court's playable area — the same screen-to-court
conversion, the <a class="" href="https://optic-yellow.com/primer#homography">homography</a>, used elsewhere in
this project. Every phantom shot sat outside that area; every real
shot, across both matches, stayed inside it, serves excepted (a legal
toss goes up and out on purpose). One rule, one exception, and the
phantom shots disappeared. Checked again against the human chart, the
night match's shot-count came back to ten of eleven — the best this
project has scored — while the day match's numbers didn't move by a
single shot: proof the fix cleaned up the one problem it targeted and
touched nothing else.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-scoreboard">The scoreboard<a href="https://optic-yellow.com/blog/the-zero-dollar-specialist#the-scoreboard" class="hash-link" aria-label="Direct link to The scoreboard" title="Direct link to The scoreboard" translate="no">​</a></h2>
<table><thead><tr><th>What we measured</th><th>SAM, the generalist</th><th>WASB, the specialist</th></tr></thead><tbody><tr><td>Ball found on screen — night match</td><td>found it 82% of the time</td><td>found it 87% of the time</td></tr><tr><td>Ball found on screen — day match, bright glare</td><td>found it 74% of the time</td><td>found it 88% of the time</td></tr><tr><td>Points counted correctly — day match, 5 points checked</td><td>1 of 5</td><td>4 of 5</td></tr><tr><td>Cost to track a match's worth of clips</td><td>a small fee per clip</td><td>$0</td></tr></tbody></table>
<p>Good, here, is the specialist matching or beating the generalist
everywhere that counts, at zero cost — which is what happened, except
on a handful of easy, brightly lit clips where the generalist still
edged ahead by a few points.</p>
<p>Sometimes the scariest bugs in a pipeline aren't the ones that break
something — they're the ones that had quietly been holding something
else together.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><p>Landscape review: 23 sources fetched, top 25 claims adversarially
verified, 25 survived. Specialist = WASB-SBDT (BMVC 2023, NTT, MIT
license), pretrained tennis weights, 6 MB, F1 94–96 on the
broadcast-tennis benchmark. TennisProject replicates our classical
stack (ball, court keypoints, homography, players, bounces). No
product/paper/repo found ingests broadcast video and emits a symbolic
shot-by-shot chart: SwingVision wants your own phone footage, TennisViz
consumes Hawk-Eye's tracking feed, Hawk-Eye runs ~EUR 60–70k/court/week.
Match Charting Project: ~17,800 matches, ~a quarter of tour matches,
Sackmann's January post pleads for more charters.</p><p>A/B, project shorthand: night match = t1, day match = t2. ~200-line
driver (hard-asserts CUDA; ours runs on MPS, 15–20 fps), frozen chart
loop untouched. SAM's disaster clips evaporate under the swap
(55%→93%, 55%→87%); its only wins are 3–7 points on easy floodlit
clips.</p><p>Regression: t1 rally length 7/11 → 5/11, all over-counts. Failed fix:
velocity magnitude — coda bounces swing 109–210 px/frame vs real hits'
8–33, a beautiful gap until t2's serves landed inside it. Fix that
held: one positional gate, serve-window exemption, three new
constants, frozen-era constants untouched — t1 lands at 10/11, t2
byte-identical before/after. All 60 dev-reel clips retracked at $0,
including 37 SAM never touched.</p></div></div></details>
<p>Session cost: $0.00. Project total: ~$4.15 of $9 — and nothing ahead of
us charges by the frame anymore.</p>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
        <item>
            <title><![CDATA[T1: Right Hardware, Wrong Sign]]></title>
            <link>https://optic-yellow.com/blog/t1-right-hardware-wrong-sign</link>
            <guid>https://optic-yellow.com/blog/t1-right-hardware-wrong-sign</guid>
            <pubDate>Fri, 10 Jul 2026 01:00:00 GMT</pubDate>
            <description><![CDATA[Graded against a real human chart for the first time, the system's biggest miss was a lefty problem, not a broken tool.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top is match point of Shapovalov's win over Nadal,
Montréal 2017 — the point that made his career. Our software's chart
assembles on the top line; a human volunteer's runs below it. The
human's line ends <code>f+1*</code> — forehand (f), closing in on the net (+), to
a corner (1), a clean winner (</em>). The software's ends with Shapovalov
flat on his back, most labels still <code>?</code>, its symbol for "couldn't
tell." That gap is what this post closes.*</p>
<p>Could software, rules locked exactly as last tuned, watch a match it
had never seen and write a chart a tennis fan would call the same
match?</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="previously">Previously<a href="https://optic-yellow.com/blog/t1-right-hardware-wrong-sign#previously" class="hash-link" aria-label="Direct link to Previously" title="Direct link to Previously" translate="no">​</a></h2>
<p>Two posts back, <a class="" href="https://optic-yellow.com/blog/m3-the-ball-gets-a-vote">the software learned to guess who hit each
shot</a> by weighing three signals — did
the ball reach that side, the serve call, where the shot landed. Last
post, <a class="" href="https://optic-yellow.com/blog/t1-answers-in-the-back">a human volunteer's chart of a real
match</a> — Nadal against Shapovalov, at
night, in Montréal — became the project's first answer key, ending a
stretch of calling its own lines: fine for practice, worthless as
proof. (Why that matters: <a class="" href="https://optic-yellow.com/primer#ground-truth">Court Vision in Plain
English</a>.) Today's rule, set before a single
clip played: nothing changes before the scorecard.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="ends-swap-the-software-had-to-learn-that-too">Ends swap. The software had to learn that too.<a href="https://optic-yellow.com/blog/t1-right-hardware-wrong-sign#ends-swap-the-software-had-to-learn-that-too" class="hash-link" aria-label="Direct link to Ends swap. The software had to learn that too." title="Direct link to Ends swap. The software had to learn that too." translate="no">​</a></h2>
<p>Every tennis fan carries this rule without thinking: players swap ends
after the first game, then after every odd-numbered game total, and
every six points in a tiebreak. Tell a fan the score is four games to
three and they'll say, without looking, which end each player stands
at — arithmetic, not a guess. The software had to learn that same
arithmetic: no coin toss to watch, just a ball arriving from the near
or far side of the screen, so it computes which end a player stands at
from the score alone.</p>
<p>That arithmetic matters beyond serve calls. When software is wrong in
a way that flips on one fact it never checked, the fault usually isn't
what it's measuring — it's a label it assumed instead of computed.
Call this the <strong>mirrored error</strong>: every miss points the same
direction, so the tool's eyes work fine and only a naming convention
flipped — a line judge with perfect eyesight calling "out" every time
she means "in." You wouldn't trust her calls, but you wouldn't send
her for glasses.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-carried-over-from-the-practice-match">What carried over from the practice match<a href="https://optic-yellow.com/blog/t1-right-hardware-wrong-sign#what-carried-over-from-the-practice-match" class="hash-link" aria-label="Direct link to What carried over from the practice match" title="Direct link to What carried over from the practice match" translate="no">​</a></h2>
<p>Stabilizing the footage, building the empty-court reference photo (the
<a class="" href="https://optic-yellow.com/primer#background-subtraction">median-frame trick</a> that erases
players and ball so anything left over stands out), and tracking
players ran on the Montréal footage unchanged. Far-side coverage
improved: 100% here versus 96% on the practice reel, because
floodlights kill the shadows that had tricked the daytime footage.</p>
<p>A new problem showed up — the fourth time something that isn't a
player has fooled the player-finder. This court's ball kids crouch at
the net posts, and the code assuming "the biggest moving blob is the
player" grabbed them, opening serve-toss windows above a ball kid's
head, once above a chair umpire's. The fix is geometry: a server stands at
the baseline, so the bootstrap checks each candidate's foot position
in real court coordinates — the <a class="" href="https://optic-yellow.com/primer#homography">screen-to-court
conversion</a> the project runs on — and rejects
anyone standing where a server never would.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="who-served-ask-the-score-not-just-the-video">Who served? Ask the score, not just the video<a href="https://optic-yellow.com/blog/t1-right-hardware-wrong-sign#who-served-ask-the-score-not-just-the-video" class="hash-link" aria-label="Direct link to Who served? Ask the score, not just the video" title="Direct link to Who served? Ask the score, not just the video" translate="no">​</a></h2>
<p>The serve detector called a direction confidently on 11 of 25 clips
here, down from 40 of 60 on the practice match — a harder test: a
different match, at night.</p>
<p>The human chart already knows who served every point; changeover
parity turns that into "which end that server stood at," once you
know where the match started. Run it both ways: "Nadal started far"
explains 11 of 14 confident calls, "near" explains only 3. Nadal
started far — a real accuracy figure of 11 of 14, about 79%, up from 6
of 8 on the practice match, checked by eye instead of a chart.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="paying-only-for-tracks-worth-paying-for">Paying only for tracks worth paying for<a href="https://optic-yellow.com/blog/t1-right-hardware-wrong-sign#paying-only-for-tracks-worth-paying-for" class="hash-link" aria-label="Direct link to Paying only for tracks worth paying for" title="Direct link to Paying only for tracks worth paying for" translate="no">​</a></h2>
<p>Ball tracking still runs behind a spending gate: a human eyeballs a
proposed box before the project pays to send it to the <a class="" href="https://optic-yellow.com/primer#wasb-vs-sam">paid,
prompt-it-yourself tracker</a>. Of 25 proposed
boxes, 13 were approved — nine on the first look, three more after the
ball-kid fix, one via a backup rule that just follows whatever's
moving fastest. Twelve were sent, $0.85 total, eleven usable; the
twelfth, a shaky approval, died twelve frames in. That check is 6 for
6 across both matches at picking winners. It stays.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-scorecard">The scorecard<a href="https://optic-yellow.com/blog/t1-right-hardware-wrong-sign#the-scorecard" class="hash-link" aria-label="Direct link to The scorecard" title="Direct link to The scorecard" translate="no">​</a></h2>
<p>Eleven points, chart locked, graded against the human's:</p>
<table><thead><tr><th>What was checked</th><th>Result</th><th>What it means</th></tr></thead><tbody><tr><td>Got the server right</td><td>7 of 11</td><td>Whose racket started the point</td></tr><tr><td>Rally length, within one shot</td><td>7 of 11</td><td>How many shots the point took</td></tr><tr><td>Serve landing spot in the box</td><td>1 of 2</td><td>Only two points had enough signal to grade</td></tr><tr><td>Forehand/backhand, exact match</td><td>2 of 11 — about 1 in 5</td><td>The weakest reading on the sheet</td></tr><tr><td>Forehand/backhand, allowing a mirror</td><td>9 of 11 — better than 4 in 5</td><td>Flip the label and it's the strongest reading</td></tr></tbody></table>
<p>That last pair is the headline — the best bad result this project has
produced. Nadal and Shapovalov are both left-handed; the software
still assumes every player is right-handed, a risk flagged and frozen
on purpose so a lefty-heavy match would expose it. A lefty's forehand
lands where a righty's backhand does, and vice versa — assume the
wrong hand and the label flips, though the geometry stays right. Nine
mirrored calls out of eleven means the software found the right side
of the body at contact almost every time — just the wrong name for it.
One line of match configuration — telling it who's left-handed — turns
today's 2 of 11 into roughly 9 of 11 at the next freeze: the cheapest
seven-point improvement this project will ever buy.</p>
<p>The rest of the misses hold up too. A tie-break rule letting a strong
serve call override a shaky forehand/backhand vote had rescued one
confusing point on the practice match; here it pushed two calls wrong
instead. And rally length runs long because some points include a
fault — a missed first serve — before the point that counts; the human
chart logs both separately, but the software counted the fault as part
of the rally. Starting the count from the first confident serve fixes
both.</p>
<p><strong>When every miss points the same direction, you haven't found a
broken tool — you've found a fact about the match it never bothered to
check.</strong></p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><ul>
<li class="">The tie-break rule that wronged two calls here fixed point_59 on the
dev reel: thin far-end tracks make touch votes lie harder than serve
priors, so its vote weights are a flagged dev-reel artifact.</li>
<li class="">Handedness was flagged as a known liability in the who-hit-it
milestone (M3) and frozen on purpose for this test.</li>
<li class="">The ballkid impostor triggered the largest-connected-component
player pick; the paid tracker throughout is SAM 3.</li>
</ul></div></div></details>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-verdict">The verdict<a href="https://optic-yellow.com/blog/t1-right-hardware-wrong-sign#the-verdict" class="hash-link" aria-label="Direct link to The verdict" title="Direct link to The verdict" translate="no">​</a></h2>
<p>An automated system charted eleven points of a tennis match from
broadcast video and got graded against a human's chart of those
points — the actual thing this project set out to test, now with a
number instead of an impression. The number is bad in the most useful
way a number can be: the biggest error has a direction, a known cause,
and a one-line fix.</p>
<p>Test-set discipline held — nothing tuned mid-grade. Session cost
$0.85; project total about $3.65 of the $9 budget. Next: handedness
goes into the match configuration, then a control match — Federer
against Haase, two right-handers — answers whether this was ever
really about left-handedness at all.</p>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
        <item>
            <title><![CDATA[T1: A Test Set With Answers in the Back]]></title>
            <link>https://optic-yellow.com/blog/t1-answers-in-the-back</link>
            <guid>https://optic-yellow.com/blog/t1-answers-in-the-back</guid>
            <pubDate>Fri, 10 Jul 2026 00:45:00 GMT</pubDate>
            <description><![CDATA[The software faces its first honest test against a match a human already scored, and the camera math needs fixing first.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top: two court outlines on the same night-match frame. Red is
the camera math borrowed as-is from the practice match; green is a fresh
fit built from this match's own lines. Red drifts visibly off the real
court — and its own accuracy numbers still said it looked fine.</em></p>
<p>Every setting this project has tuned so far was checked by the one person
who tuned it. That's a chair umpire overruling his own call and then
congratulating himself for getting it right — the call might be correct,
but nobody should trust it just because he says so.</p>
<p>Previously: the plan is to feed broadcast video in and get the same
shot-by-shot notation out that Match Charting Project volunteers
hand-code by watching real matches (<a class="" href="https://optic-yellow.com/blog/auto-charting-kickoff">cv-01</a>).
Every dial since then — how confidently motion counts as a swing, how the
serve toss gets spotted, whose shot each ball is credited to — was tuned
on one practice match, Zverev–Gasquet, and checked with my own eyes
(<a class="" href="https://optic-yellow.com/blog/m3-the-ball-gets-a-vote">cv-09</a>). So was the
<a class="" href="https://optic-yellow.com/primer#homography">homography</a> — the rule, built from the court's
painted lines, that converts a screen position into a court position
(<a class="" href="https://optic-yellow.com/blog/m1-pixels-to-meters">cv-03</a>).</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="somebody-else-has-to-hold-the-answer-key">Somebody else has to hold the answer key<a href="https://optic-yellow.com/blog/t1-answers-in-the-back#somebody-else-has-to-hold-the-answer-key" class="hash-link" aria-label="Direct link to Somebody else has to hold the answer key" title="Direct link to Somebody else has to hold the answer key" translate="no">​</a></h2>
<p>A line judge who grades his own overrules isn't cheating, exactly — he's
just human, and human eyes trust their own first read. Tennis's fix is a
second set of eyes that never made the call. This project needs the same
fix: an answer key built by someone who never saw its output. This
devlog calls that <strong>ground truth</strong> (more: <a class="" href="https://optic-yellow.com/primer#ground-truth">Court Vision in Plain
English</a>) — and Jeff Sackmann's Match Charting
Project supplies it: volunteers who hand-code real matches, shot by
shot, in the exact notation this project is trying to produce.</p>
<p>There's a second half to the rule, the half that makes a test count
instead of a rehearsal: the moment a match gets used to fix a bug, it
becomes practice, not proof. So every setting gets locked first —
written down, untouched — before the software meets a match it never
influenced. Freeze first, grade second: that's a <strong>benchmark</strong>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="finding-a-strangers-homework">Finding a stranger's homework<a href="https://optic-yellow.com/blog/t1-answers-in-the-back#finding-a-strangers-homework" class="hash-link" aria-label="Direct link to Finding a stranger's homework" title="Direct link to Finding a stranger's homework" translate="no">​</a></h2>
<p>The Match Charting Project has hand-charted more than 7,500 men's
matches; the practice match isn't one of them. A match from the same
tournament, court, and broadcast package is: Canada Masters 2017, Nadal
vs. Shapovalov, round of 16 — 225 points, charted point by point, at
night under floodlights, both players left-handed. Neither fact was
tested on the practice match: its shot calls assume a right-hander, its
motion settings were tuned in daylight. Same court and graphics package,
so any failure here traces to what actually changed.</p>
<p>Held in reserve: Federer vs. Haase, the semifinal — day, both
right-handed, the closest match here to practice conditions. If the
software struggles there too, lefties and floodlights aren't why.</p>
<p>The test's rules were locked before any of it ran: no setting changes
once grading starts (one part stayed adjustable — appendix). For every
clip that lines up with a human record, the check compares who served,
where the serve landed, how long the rally ran, and how often the
shot-by-shot calls match — counting only what the software committed to,
not its shrugs. That 225-point answer key is public, free with
attribution, and cost nothing to get.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="matching-clips-to-the-human-chart">Matching clips to the human chart<a href="https://optic-yellow.com/blog/t1-answers-in-the-back#matching-clips-to-the-human-chart" class="hash-link" aria-label="Direct link to Matching clips to the human chart" title="Direct link to Matching clips to the human chart" translate="no">​</a></h2>
<p>Each clip first has to be found, then matched to a point. Finding them
gave the practice match the most trouble; here it barely put up a
fight — point the software at the file paths and the new camera math,
and 25 clips of real play fell out, no setting touched. Matching them
means reading a score bug — the little scoreboard graphic in the corner,
frozen pixel-for-pixel during a point, changing only when the point ends
(more: <a class="" href="https://optic-yellow.com/primer#score-bug">Court Vision in Plain English</a>) — and finding
the line in the human's chart with that same score.</p>
<p>One convention nearly wrecked it: the human record lists the <em>server's</em>
score first; this match's bug always lists Nadal's first, serving or
not. Every Shapovalov service game disagreed on which number came
first, costing six wrongly-matched points before I caught the pattern.
Before the fix, 17 of 25 clips lined up; after it, 23 of 25 did.</p>
<p>The remaining two aren't errors, they're genuine ties: a 40–Advantage
score that happened twice in the same game, and a deuce from a game that
reached deuce ten times — indistinguishable by score alone. The process
even caught a mistake of mine: I'd misread one clip's serve indicator by
eye, and the human chart showed Shapovalov had actually served.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-camera-math-doesnt-carry-over">The camera math doesn't carry over<a href="https://optic-yellow.com/blog/t1-answers-in-the-back#the-camera-math-doesnt-carry-over" class="hash-link" aria-label="Direct link to The camera math doesn't carry over" title="Direct link to The camera math doesn't carry over" translate="no">​</a></h2>
<p>The camera math built on the practice match missed by about 20 pixels on
this new footage — different filming day, camera remounted. Refitting it
took three failed attempts before a fourth worked.</p>
<p>Attempt one reused the first homography experiment's approach,
unchanged. Under floodlights, the paint boundary where the court's blue
surface meets its green surround reflects light almost exactly like a
real white line. The fit grabbed that paint edge and drew a court 15 to
20 pixels too big.</p>
<p>Attempt two made the software pickier about what counts as a white
line — which killed the paint problem, and the far baseline and both
sidelines along with it. Not enough real lines left to build a court
from.</p>
<p>Attempt three searched narrow bands near where the practice match's
lines had been. It failed more quietly: one band locked onto ground
paint 70 pixels off; another mixed in doubles pixels while hunting for a
singles line. The fit then reported a great accuracy number for
itself — but that number only measures agreement with the pixels the fit
chose to trust, and some of those were the wrong paint. Drawn onto the
actual frame and eyeballed, the center line was obviously off, by 32
pixels: ten times what the fit had reported.</p>
<p>What worked: a human — me — zoomed the frame in six times and picked the
court's four corners by eye, once. The same kind of one-time nudge the
very first tracking experiment needed, when someone had to draw a box
around the ball before it would track
(<a class="" href="https://optic-yellow.com/blog/m0-first-tracked-rally">cv-02</a>). Even that failed twice at
first: the corners read near the baseline were the <em>singles</em> corners,
not the doubles corners the court is actually measured to, further out
and blurred into paint at the frame's edge. Two pieces of hand-worked
geometry confirmed which was which. The final fit mixed far doubles
corners with near singles corners, holding attempt two's lines back as
an independent check.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="where-things-landed">Where things landed<a href="https://optic-yellow.com/blog/t1-answers-in-the-back#where-things-landed" class="hash-link" aria-label="Direct link to Where things landed" title="Direct link to Where things landed" translate="no">​</a></h2>
<table><thead><tr><th>What got checked</th><th>Result</th><th>What "good" looks like</th></tr></thead><tbody><tr><td>Clips found in the highlights reel</td><td>25 of 25</td><td>Every real point located, none missed</td></tr><tr><td>Clips matched to a specific human-charted point</td><td>23 of 25 (2 are genuine ties, not errors)</td><td>As close to all of them as honest ambiguity allows</td></tr><tr><td>Final camera-math accuracy</td><td>Service line 0.8 px · Baseline 1.4 px · Center line 6.1 px</td><td>Single-digit pixels, not tens</td></tr><tr><td>Cost of both answer keys</td><td>$0.00</td><td>Free — public data, no paid tools</td></tr></tbody></table>
<p>This is the third time this project has hit the same wall, so it's a
standing rule now: a number can vouch for a wrong answer, but a drawn
line on the actual picture can't lie the same way. Every camera-math fit
gets rendered onto the video and eyeballed before it's trusted — which is
exactly what the two colored outlines in the clip up top are for.</p>
<p>Twenty-five clips found, twenty-three tied to a line from a human's
chart, a court that measures accurately, every setting still frozen.
Cost for both sessions: $0.00 — public data and an afternoon staring at
pixels. Next: turn this into an actual scorecard, machine against human,
point by point.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><p>Footage: the 8:21 TennisTV highlights reel. Ground truth lives in the
repo under <code>data/mcp/</code> (CC BY-NC-SA, attributed). Eval protocol, exact
terms: constants frozen; segmentation could still be re-tuned for the new
reel's cuts (finding points isn't the hypothesis — charting them is);
metrics per aligned point are server end, serve zone, rally length, and
letter accuracy over committed letters only; coverage reported beside
accuracy; no silent cherry-picking.</p><p>Score-bug join: MCP's <code>Pts</code> column is server-first, the on-screen bug is
Nadal-first. <code>point_06</code>'s serve marker was misread by eye; the join
corrected it.</p><p>Homography attempt 1 was M1's recipe, verbatim; under floodlights the
blue-to-green paint boundary reads V≈155, S≈55, the same signature as a
real line at grazing angle. Attempt 3's guided bands were seeded from
the dev prior; its self-reported residual was a beautiful 2.9 px,
against the 32 px actually on the ground — plausible-but-wrong, caught
only by rendering the reprojection and looking at it.</p><p>The fix echoes the M0 ball-click precedent: one human input per match.
The two cross-checks that settled which corners were real — far-corner
midpoint lands at x=603 against a measured center line of 606, near-corner
reads implied 643; pixels-per-meter is linear in image y (48 at the far
baseline through 80 at the near service line), extrapolating to ~100 at
the near baseline and predicting a doubles span of ~1095 pixels, exactly
the white band's raw extent. The final fit held attempt 2's tight-mask
lines back as untouched validators rather than fitting to them.</p></div></div></details>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
        <item>
            <title><![CDATA[M3: The Ball Gets a Vote]]></title>
            <link>https://optic-yellow.com/blog/m3-the-ball-gets-a-vote</link>
            <guid>https://optic-yellow.com/blog/m3-the-ball-gets-a-vote</guid>
            <pubDate>Fri, 10 Jul 2026 00:30:00 GMT</pubDate>
            <description><![CDATA[When the software can't tell who hit the ball, it holds a vote instead of guessing — and the ball outvotes the serve call, on camera.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top: a single point charting itself live. A box tracks the
ball, a second box flashes onto whichever player the software thinks
just struck it, and a line of notation builds along the bottom, shot by
shot. Late in the clip, watch that second box drift off the court and
land on a spectator — hang onto that, it matters later.</em></p>
<p>Freeze a rally at the instant of contact, and sometimes you genuinely
can't tell who just hit the ball — it's caught in the air, equally far
from both players.</p>
<p>This project is teaching software to watch broadcast tennis and write
out the same shot-by-shot notation a human volunteer hand-writes for the
Match Charting Project (<a class="" href="https://optic-yellow.com/blog/auto-charting-kickoff">cv-01</a>). The <a class="" href="https://optic-yellow.com/blog/m3-toss-is-the-bootstrap">last
entry</a> solved the pipeline's final
manual step — the serve toss rises alone above the server's head, so the
software now finds and boxes the game ball itself — and produced the
first complete points, start to finish. But the column recording who
struck each shot broke tennis's own rule that hitters alternate,
whenever the ball was caught mid-air at contact, equally far from both
players.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-vote-not-a-guess">A vote, not a guess<a href="https://optic-yellow.com/blog/m3-the-ball-gets-a-vote#a-vote-not-a-guess" class="hash-link" aria-label="Direct link to A vote, not a guess" title="Direct link to A vote, not a guess" translate="no">​</a></h2>
<p>Tennis already has a model for resolving exactly this kind of
disagreement: a chair umpire weighing a disputed call. No single witness
gets trusted blindly — an electronic call, a line judge's shout, the
mark on the court can each say something different — and the umpire
decides which to believe, not just which spoke first.</p>
<p>That's the idea that replaced "whoever's closest." It leans on the
sport's strongest fact: hitters alternate. A rally has exactly two
possible stories — near player struck first, or far player did — so
instead of deciding shot by shot, the software decides once per rally,
scoring both stories against independent evidence and keeping whichever
one wins. That's a <strong>weighted vote</strong>: several independent signals, each
trusted by a different amount, added together into one decision.</p>
<p>The alternating pattern itself is allowed to bend in exactly one place:
if the ball vanishes from the tracker for a stretch of frames, a hit
could be hiding inside that gap, undetected — and only there can the
near-far-near-far pattern skip a beat.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="proximity-fails-twice">Proximity fails, twice<a href="https://optic-yellow.com/blog/m3-the-ball-gets-a-vote#proximity-fails-twice" class="hash-link" aria-label="Direct link to Proximity fails, twice" title="Direct link to Proximity fails, twice" translate="no">​</a></h2>
<p>The first fix tried was the obvious one: whoever's closest to the ball
at contact hit it. It failed immediately — right at the instant a hit
is detected, the ball is often out in open court, closer to neither
player than the other.</p>
<p>The next idea reached for physics instead of position: after a real
hit, the ball should move away from whoever struck it. Converted into
court coordinates, that direction came back backwards after hits from
<em>both</em> ends. The culprit is the <strong>homography</strong> — the recipe that turns
a spot on the TV screen into a spot on the court diagram, more on it in
<a class="" href="https://optic-yellow.com/primer#homography">Court Vision in Plain English</a> — which only tells
the truth for things touching the ground. It's been lying about anything
airborne since the <a class="" href="https://optic-yellow.com/blog/m1-pixels-to-meters">very first post that built
it</a>; bounces happen on the ground, so they
were always honest, and nobody had noticed the lie until this rule
leaned on it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-evidence-weighted">The evidence, weighted<a href="https://optic-yellow.com/blog/m3-the-ball-gets-a-vote#the-evidence-weighted" class="hash-link" aria-label="Direct link to The evidence, weighted" title="Direct link to The evidence, weighted" translate="no">​</a></h2>
<p>Three witnesses get called per rally, in order of trust. <strong>Touch</strong>
counts most: did the ball reach exactly one player's own patch of court
during the contact window? <strong>The serve call</strong> counts less — a starting
hunch, not gospel. <strong>Which half the ball landed in</strong> counts least: a
bounce detector — the "speed suddenly collapses" signature from <a class="" href="https://optic-yellow.com/primer#hits-and-bounces">Court
Vision in Plain English</a> — only watches the
far half of the court right now, so it can vote "the near player hit
it" but not the other way round yet. Half a witness, but an honest one.
Every vote that loses stays on the record instead of getting erased — a
built-in honesty check, so a witness wrong more often than it should be
can't quietly hide its bad calls.</p>
<p>Making the serve call outvotable paid off the first time it mattered.
In one point, the serve detector was confident the point started at the
near end; touch and landing evidence together pointed far; the vote
won. A frame-by-frame check of the footage sided with the vote.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-letter-only-counts-if-the-ball-got-there">A letter only counts if the ball got there<a href="https://optic-yellow.com/blog/m3-the-ball-gets-a-vote#a-letter-only-counts-if-the-ball-got-there" class="hash-link" aria-label="Direct link to A letter only counts if the ball got there" title="Direct link to A letter only counts if the ball got there" translate="no">​</a></h2>
<p>Once a rally's pattern is settled, proximity becomes trustworthy again
for a narrower job: pinning down the exact contact frame, now that it's
known which player to look near. But the stroke-type letter — forehand
or backhand — only gets written down if the ball demonstrably reached
that player's own patch of court, not just "somewhere nearby." That
patch scales with how big the player looks on screen: the far player is
only about 55 pixels tall, so one fixed-size box for both would reject
nearly every far-end letter before it had a chance.</p>
<p>Tracking boxes also go rogue at the worst moments — one wandered onto a
spectator standing by a Perrier cooler, another landed on a court shadow
instead of a person. That's the box that drifted off-court in the clip
up top. Whenever that happens, the letter stays a question mark — the
same "declined to guess" mark explained in <a class="" href="https://optic-yellow.com/primer#notation">the notation
legend</a> — rather than a wrong one.</p>
<p>Read with that legend, the clip's string is: a serve down the T (<code>s6</code>);
a forehand toward one side (<code>f1</code>); a forehand where the stroke is
certain but the side isn't (<code>f?</code>); a backhand toward the other side
(<code>b3</code>); a backhand with the side unresolved (<code>b?</code>); a shot nobody's sure
who hit, though the ball's own path still says the other side (<code>?3</code>); a
forehand back up the middle (<code>f2</code>); and an ending not written yet, so
it's <code>?</code> too. Spot-checked against video, the committed letters held up.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="winning-the-reel-before-spending-a-cent">Winning the reel before spending a cent<a href="https://optic-yellow.com/blog/m3-the-ball-gets-a-vote#winning-the-reel-before-spending-a-cent" class="hash-link" aria-label="Direct link to Winning the reel before spending a cent" title="Direct link to Winning the reel before spending a cent" translate="no">​</a></h2>
<p>Before any paid tracking ran, the plan got a free review: every
candidate starting box went into one grid of still images, eyeballed by
hand. That alone killed most of the backup ball-finder's picks — the
method that kicks in with no confident serve toss, chasing "the fastest
small thing moving," fooled by lens glare, shadow edges, court-line
junctions, the net band, a FedEx cooler, even a ballkid. Of 29 backup
candidates reviewed, only 3 survived. Three points moving in a straight
line isn't proof of a flying ball, especially mid-camera-pan — a
background object can fake that chain too.</p>
<p>The same pass fixed the main method for free. The search box for the
toss above a server's head used to be a fixed size, so on far-end serves
it was searching well above a player barely 55 pixels tall — effectively
searching the crowd. Scaling that box to the player's own size, plus a
filter dropping racket-shaped blobs, recovered seven far-end tosses the
old search had thrown into the stands.</p>
<p>23 clips went to the paid tracker; 18 came back usable. Five were lost
outright — SAM, the promptable tracker from <a class="" href="https://optic-yellow.com/primer#wasb-vs-sam">Court Vision in Plain
English</a>, locking onto a stuck box instead of the
ball, including every borderline case that had looked "good enough" on
the free review. The eyeball review, not any automatic score, was the
real ground truth.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-scoreboard">The scoreboard<a href="https://optic-yellow.com/blog/m3-the-ball-gets-a-vote#the-scoreboard" class="hash-link" aria-label="Direct link to The scoreboard" title="Direct link to The scoreboard" translate="no">​</a></h2>
<table><thead><tr><th>What got measured</th><th>This session</th><th>What "good" looks like</th></tr></thead><tbody><tr><td>Full points charted</td><td>21, up from 5 before this session</td><td>Every point in the reel, eventually</td></tr><tr><td>Serve-toss ball-finder</td><td>18 of 25 tries, 72%</td><td>This is the primary method — most tries should land</td></tr><tr><td>Backup ball-finder, after free review</td><td>3 of 29 candidates, about 1 in 10</td><td>A rare fallback, not a real plan</td></tr><tr><td>Clips sent to the paid tracker</td><td>23 sent, 18 came back usable</td><td>Every clip returns a clean track</td></tr></tbody></table>
<p>Both serve codes finally show up correctly on far-end serves — six of
them — s5 (to the body) and s6 (down the T), the <a class="" href="https://optic-yellow.com/primer#notation">zone
codes</a> that had existed in the code for a while but
were never switched on; serves had been getting a cruder guess instead,
the same rough "which third of the court" scale every other shot uses.
One string came back fully committed: <code>s5f3b3?</code> — a body serve, a
forehand toward one side, a backhand toward the same side, and the same
unwritten ending every string gets right now.</p>
<p>Put it the way a tennis fan would: don't trust whoever's standing
closest, trust the best evidence — and let it overrule your own serve
call if it has to.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><ul>
<li class="">Striker-assignment history: v1 (nearest player at the event frame)
died on the cusp problem. v2 (post-hit court-frame ball velocity) died
on the homography's ground-plane assumption — airborne "court
velocity" is dominated by vertical image-space motion and reads
negative after hits from both ends; true since M1, invisible until v2
leaned on it. v3 (current): two candidate assignments per rally
(near-first / far-first) plus one permitted parity flip at a
ball-track hole — confirmed on SAM losing the ball for frames 54–78 of
point_53, exactly where a far-end hit had gone missing; the flip is
frame-verified.</li>
<li class="">Vote weights: touch = 3, serve call = 2, landing half = 1 (the
collapse/bounce detector, far-half-only by construction — one-sided,
honest). point_59: SERVER-OVERRIDE — the gated serve detector said
near, touch + landing said far, frame check confirms far was right.</li>
<li class="">Letter committal: refines to nearest approach to the <em>assigned</em>
striker. f/b commits only past a height-scaled box gate (a fixed
pixel gate previously rejected every far-end letter, since the far
player is ~55px tall). <code>?</code> cases: a "far" box on a spectator by the
Perrier cooler, a "near" box on a court shadow. Frame-verified
commits: 3/3 on point_16, plus a point_24 spot-check.</li>
<li class="">Dry-run montage: all 54 prompt boxes reviewed pre-spend. Killed 25/29
mover-fallback bootstraps (glare, shadow edges, line junctions, net
band, a FedEx cooler, a ballkid). Toss-bootstrap fixes: search window
now scales with apparent height (was a fixed 130px above a 55px far
server); ball-shape filter drops elongated racquet-arc blobs.
Recovered 7 far-end tosses.</li>
<li class="">API run: 23 clips sent, 18 tracks returned, 5 lost outright (giant
stuck boxes, every "weak accept"). Two lessons on the record: an
all-dropped result used to crash the batch (now a graceful skip); fal
chunks long videos — past ~490 frames it throws "No prompts available
for this video chunk." point_55 worked trimmed to 450 frames;
point_58 (893 frames, the 49-shot point) is parked until
split-and-stitch exists.</li>
<li class=""><code>serve_zone()</code> is actually <em>called</em> now — v1 had defined the function
and then coded serves by full-court thirds anyway. Conflicts column
(votes that lost) kept on record as the honesty metric.</li>
</ul><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">point_36:  s6f1f?b3b??3f2?   (loop v2 demo clip — far box drifts onto a spectator)</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">point_24:  s5f3b3?           (complete string, spot-checked)</span><br></span></code></pre></div></div></div></div></details>
<p>Session cost: about $1.50. Project total: about $2.80 of the $9 budget —
this low because the free eyeball review caught problems before they
became paid API calls. The toss bootstrap carried this session; the
backup ball-finder still needs a rethink, not a tune-up.</p>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
        <item>
            <title><![CDATA[M3: The Toss Is the Bootstrap]]></title>
            <link>https://optic-yellow.com/blog/m3-toss-is-the-bootstrap</link>
            <guid>https://optic-yellow.com/blog/m3-toss-is-the-bootstrap</guid>
            <pubDate>Fri, 10 Jul 2026 00:15:00 GMT</pubDate>
            <description><![CDATA[The serve toss lets the software find the ball itself, and it charts its first full point without a human drawing a single box.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top: a whole point, serve included, charting itself — the
tracking box locks onto the ball at the toss on its own, no clicking,
and the notation string builds along the bottom. First time this
pipeline has gone start to finish, and it's still a little rough.</em></p>
<p>Every rally tracked so far needed a person to draw a box around the ball
by hand first. Fine once. Absurd sixty times. The fix: a fact about
tennis itself. Every point begins the same way, and the software can now
spot that beginning on its own.</p>
<p>Two posts back, the first experiment taught a promptable tracking AI
called <a class="" href="https://optic-yellow.com/primer#wasb-vs-sam">SAM 3</a> to hold a tennis ball through a
rally — but only after a human circled that exact ball with a box, by
hand: <a class="" href="https://optic-yellow.com/blog/m0-first-tracked-rally">it could follow once pointed at</a>,
never find one alone. <a class="" href="https://optic-yellow.com/blog/m3-static-camera-isnt">Last time out ended on a
debt</a>: its notation string was a rally's
middle, no beginning, because nothing yet knew where a point starts.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="every-point-starts-the-same-way">Every point starts the same way<a href="https://optic-yellow.com/blog/m3-toss-is-the-bootstrap#every-point-starts-the-same-way" class="hash-link" aria-label="Direct link to Every point starts the same way" title="Direct link to Every point starts the same way" translate="no">​</a></h2>
<p>No rally begins in the middle. Every point opens with one unmistakable
action: a player stands still, tosses one ball straight up, and swings.
Nothing else moves like that — the receiver stands still, every spare
ball sits still in a ball kid's hands, and the one thing rising alone is
the game ball.</p>
<p><strong>The bootstrap:</strong> instead of a person pointing at the ball every clip,
the pipeline uses something tennis always does anyway — the toss — to
point at the ball for itself. It already compares each frame to a photo
of the empty court and flags what's different, the same <a class="" href="https://optic-yellow.com/primer#background-subtraction">trick that
finds the players for free</a>. The only
new blob near a still server, right before contact, is the rising toss —
the software boxes it itself (<a class="" href="https://optic-yellow.com/primer#detection-vs-tracking">finding, not yet
following</a>), then hands the box to the
tracking software. Detect once, then track. The click is gone.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-impostors-once-more">The impostors, once more<a href="https://optic-yellow.com/blog/m3-toss-is-the-bootstrap#the-impostors-once-more" class="hash-link" aria-label="Direct link to The impostors, once more" title="Direct link to The impostors, once more" translate="no">​</a></h2>
<p>The pipeline has been fooled before, by ball kids holding spare balls
and a line judge who erased himself standing still. This time it was
broadcast graphics. Without a confident serve, the software falls back
to chasing the fastest small mover on court — and its first two guesses
were the scoreboard graphic (<a class="" href="https://optic-yellow.com/primer#score-bug">more on that box
later</a>) flipping on every score change, and a small
net sign vibrating in the breeze.</p>
<p>The fix: a ball in flight keeps one direction and one rough speed, frame
after frame — flying <em>ballistically</em>. A flickering graphic doesn't fly
anywhere, so requiring three straight consistent frames knocked both
out. Two clips still beat it, fooled by lens glare off the court's own
lines during a camera pan — unsolved, noted rather than hidden.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-dribble-charts-itself">The dribble charts itself<a href="https://optic-yellow.com/blog/m3-toss-is-the-bootstrap#the-dribble-charts-itself" class="hash-link" aria-label="Direct link to The dribble charts itself" title="Direct link to The dribble charts itself" translate="no">​</a></h2>
<p>Six clips ran through the paid tracking service for 55 cents total,
less than a candy bar. Those tracks met the <a class="" href="https://optic-yellow.com/primer#hits-and-bounces">hit-and-bounce
reader</a> on a <em>complete</em> point for the first
time, and found something no mid-rally clip could show: <strong>servers
dribble before the toss.</strong> Bouncing to yourself is a real hit and a real
bounce, over and over, so the detector — correctly, by its own rules —
charted the warm-up as rally. Fix: the software already knows which
frame the point starts on. Discard anything before it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-first-point-start-to-finish">The first point, start to finish<a href="https://optic-yellow.com/blog/m3-toss-is-the-bootstrap#the-first-point-start-to-finish" class="hash-link" aria-label="Direct link to The first point, start to finish" title="Direct link to The first point, start to finish" translate="no">​</a></h2>
<p>Two clips produced a full serve-to-final-shot line with nobody typing
mid-point. One reads, shot by shot: serve toward the middle (<code>s2</code>), a
backhand the software couldn't place (<code>b?</code>), backhand wide (<code>b1</code>),
forehand same side (<code>f1</code>), forehand back to the middle (<code>f2</code>), backhand
wide the other way (<code>b3</code>), two more backhands, and an ending it couldn't
call — eight shots, serve confirmed right. The other was two shots and
out: an unclear serve, one unplaced forehand return, an ending it
couldn't call.</p>
<p>Checking both by eye: serve call right, shot count rally-shaped — but
the column saying <em>who</em> hit each shot broke the serve-return pattern
whenever the guess for contact caught the ball mid-air, equally far from
both players. Nearest-player-to-the-ball means nothing once the ball is
closest to nobody. The letters aren't trustworthy yet; the shape under
them is.</p>
<table><thead><tr><th>What got checked</th><th>What came back</th><th>Good enough?</th></tr></thead><tbody><tr><td>Serve call</td><td>Right, both points</td><td>Yes</td></tr><tr><td>Shot count</td><td>Rally-shaped</td><td>Yes</td></tr><tr><td>Who hit each shot</td><td>Breaks on mid-air contact</td><td>Not yet</td></tr><tr><td>Broadcast impostors</td><td>Score bug + net sign caught; 2 clips still fooled</td><td>Mostly</td></tr><tr><td>Cost</td><td>55 cents, six clips</td><td>Well inside budget</td></tr></tbody></table>
<p>Say it the way a tennis fan would: you don't need to point at the ball
if you already know every point starts with a toss.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><ul>
<li class=""><strong>Click, closed.</strong> M0's flagged manual box-prompt: closed. Serve toss
→ auto box prompt for SAM 3, from the frame-minus-plate difference
image at the serve detector's known toss location.</li>
<li class=""><strong>Ballistic filter, exact.</strong> 3-point chain: consistent direction,
consistent per-frame step. Line-junction glare during camera pans
still passes it on 2 clips — unsolved, parked.</li>
<li class=""><strong>Pilot run.</strong> 6 clips through SAM 3, $0.55 total. First attempt hit a
request validation error — free lesson that the API wants integer
pixel coordinates, not floats.</li>
<li class=""><strong>Detector.</strong> M2's v4, fps now a parameter instead of baked-in 30,
first run over full point clips (previously mid-rally excerpts only).</li>
<li class=""><strong>Two charted points, raw:</strong></li>
</ul><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">point_16:  s2b?b1f1f2b3b?b3?     (serve ✓ — 8 shots, rally-shaped)</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">point_31:  s?f??                 (short point: serve, return, over)</span><br></span></code></pre></div></div><ul>
<li class=""><strong>Deferred, on purpose.</strong> 48 of 54 clips not run this session — known
fixes (alternation-aware striker assignment, contact-frame refinement,
sparse-track handling) are free to iterate on the six already paid
for. Running cost: pilot $0.55, project total ~$1.30 of $9.</li>
</ul></div></div></details>
<p>This session's six clips cost 55 cents; project total is about $1.30 of
a $9 budget. 48 clips stay untouched on purpose — every fix still ahead
is free to try on the six already paid for.</p>
<p>The point string that opened this stretch read <code>??2?1?1?2?2?2???</code>,
almost all question marks, no <code>s</code> anywhere: a rally with a middle and no
beginning. It now starts with an <code>s</code>.</p>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
        <item>
            <title><![CDATA[M3: The Static Camera Isn't]]></title>
            <link>https://optic-yellow.com/blog/m3-static-camera-isnt</link>
            <guid>https://optic-yellow.com/blog/m3-static-camera-isnt</guid>
            <pubDate>Thu, 09 Jul 2026 23:59:00 GMT</pubDate>
            <description><![CDATA[The 'still' broadcast camera actually drifts mid-point — and that breaks a free trick for spotting players in video.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top: every serve the pipeline felt sure about, clip
after clip — which player served, and which side of the court, deuce
or ad, flashed the instant the toss goes up.</em></p>
<p>Sixty broadcast clips are sitting there ready to be charted, and every
one starts with the same problem: telling an actual tennis player
apart from everyone else standing near a court who isn't one.</p>
<p><strong>Previously:</strong> <a class="" href="https://optic-yellow.com/blog/m3-the-cutting-room">the last post</a> sliced a
highlights reel into sixty complete-point clips using screen-to-court
math as a camera fingerprint. That math, the <strong>homography</strong> — matching
the court's painted lines to its real dimensions to turn screen
position into court position, from <a class="" href="https://optic-yellow.com/blog/m1-pixels-to-meters">Pixels to
Meters</a> — carries a free trick: median-blend
many frames of the same court and the players, who never stand still
for long, vanish into a photo of an empty court. <a class="" href="https://optic-yellow.com/blog/m3-who-hit-that">Two posts
later</a>, that trick already found both players
in one rally well enough to call every forehand and backhand. Doing it
across sixty new clips would otherwise mean paying <strong>SAM 3</strong> — the
pay-per-prompt "find that object" AI that tracked a ball for about
sixty cents in <a class="" href="https://optic-yellow.com/blog/m0-first-tracked-rally">the second
post</a> — roughly $0.15 a clip, $9 for the
batch, every rerun.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="comparing-two-photos-of-the-court">Comparing two photos of the court<a href="https://optic-yellow.com/blog/m3-static-camera-isnt#comparing-two-photos-of-the-court" class="hash-link" aria-label="Direct link to Comparing two photos of the court" title="Direct link to Comparing two photos of the court" translate="no">​</a></h2>
<p>This session tried to cash in that free trick everywhere: build the
empty-court photo per clip, then flag whatever pixel changed as a
player. One catch — both photos need the same camera position. A
broadcast camera looks fixed, but any camera operator worth their job
nudges the shot to keep the rally centered, the way your own eyes
would tracking the ball from courtside. A pixel-by-pixel comparison
notices every nudge: the baseline in one photo no longer sits on the
baseline in the other. It's like tracing a picture on an Etch A
Sketch, then shaking the toy before checking your work — nothing
lines up.</p>
<p>The fix: <strong>stabilization</strong>, nudging every frame back into line with
the others before comparing anything, so the same pixel always means
the same spot on the court. More on why the empty-court trick needs a
still camera: <a class="" href="https://optic-yellow.com/primer#background-subtraction">Court Vision in Plain
English</a>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-camera-that-wasnt-sitting-still">The camera that wasn't sitting still<a href="https://optic-yellow.com/blog/m3-static-camera-isnt#the-camera-that-wasnt-sitting-still" class="hash-link" aria-label="Direct link to The camera that wasn't sitting still" title="Direct link to The camera that wasn't sitting still" translate="no">​</a></h2>
<p>That math, first fit two posts back on a sixteen-second rally, found
almost no camera movement — so the code kept one screen-to-court
conversion for the whole clip. A full point can run a minute or more,
and these sixty clips run that long. Aligning every frame of a clip
to its own first frame and measuring the gap told a different story:</p>
<p><img decoding="async" loading="lazy" alt="Line plot of camera x and y shift across a point clip, showing a spike to 32 pixels mid-point" src="https://optic-yellow.com/assets/images/m3-camera-pan-6f1e81fe1d0e638f2621a185ad53538d.webp" width="1500" height="510" class="img_ev3q"></p>
<p><em>The broadcast camera, measured, over one point: a sharp jump — as
much as 32 pixels — mid-point, on top of slow drift throughout.</em></p>
<p>The camera operator was following the play — invisible to a viewer,
fatal to the comparison. It blurs the empty-court photo, and near the
far baseline, thirty-some pixels of drift is worth <em>meters</em> of
court-position error. Every frame now gets stabilized first.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-far-player-who-turned-into-a-ghost">The far player who turned into a ghost<a href="https://optic-yellow.com/blog/m3-static-camera-isnt#the-far-player-who-turned-into-a-ghost" class="hash-link" aria-label="Direct link to The far player who turned into a ghost" title="Direct link to The far player who turned into a ghost" translate="no">​</a></h2>
<p>With frames stabilized, the near player — big, always moving — showed
up in essentially 100% of frames; the far player, barely 35%. The
empty-court photo had swallowed him.</p>
<p>He's small, and barely moves between points, so a photo built from
one clip bakes in a faint standing copy of him — the same trick that
erased <a class="" href="https://optic-yellow.com/blog/m3-who-hit-that">a stationary line judge</a> two posts
ago.</p>
<p>The fix: build the photo from five clips instead of one — this clip
plus its four nearest neighbors, aligned and blended by taking the
middle value at every pixel again. Players stand somewhere different
every point, so five ghostly copies never land on the same pixel
twice, and the blend votes them all out. Far-player coverage: 35% to
96%. Near player: still ~100%. Cost: nothing.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="everyone-near-the-court-looks-like-a-player">Everyone near the court looks like a player<a href="https://optic-yellow.com/blog/m3-static-camera-isnt#everyone-near-the-court-looks-like-a-player" class="hash-link" aria-label="Direct link to Everyone near the court looks like a player" title="Direct link to Everyone near the court looks like a player" translate="no">​</a></h2>
<p>The first serve-detector run flagged several clips with the far
player standing 13.5 meters from the court's center, on a court only
10.97 meters wide. Nobody serves from outside their own court.</p>
<p>Those weren't players. They were <strong>ballkids</strong>, crouched past the
doubles sidelines inside the patch of court scanned for the far
player, shifting weight just enough to out-move a motionless server.
Third case of mistaken identity: <a class="" href="https://optic-yellow.com/blog/m0-first-tracked-rally">the very first
session</a> swept spare-ball-holding
ballkids into "the tennis ball," and <a class="" href="https://optic-yellow.com/blog/m3-who-hit-that">two posts
ago</a> a stationary line judge got swept into
"the empty court." To a pixel-by-pixel comparison, anyone standing
near a court looks like they belong on it. Fix: the far-side scan
region is now cut tight to the court; the near side stays generous.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="guessing-who-served-from-posture-alone">Guessing who served, from posture alone<a href="https://optic-yellow.com/blog/m3-static-camera-isnt#guessing-who-served-from-posture-alone" class="hash-link" aria-label="Direct link to Guessing who served, from posture alone" title="Direct link to Guessing who served, from posture alone" translate="no">​</a></h2>
<p>With players tracked, the serve should be readable from posture: the
server hugs the center mark, the returner waits wide — which side of
the mark gives away deuce or ad, for free — and the toss stretches
the server's outline to full reach, so blob height should spike right
at the toss.</p>
<p>Version one checked only distance to the center mark, and failed
fast: some clips open mid-rally with no toss to find, some end on a
leftover zoom fragment, and — the costly lesson — returners often
stand nearly as far from the mark as a server does, so a return swing
can read as a toss.</p>
<p><img decoding="async" loading="lazy" alt="Five-frame strip of a detected serve: toss rising, full reach at the marked frame, racket drop after" src="https://optic-yellow.com/assets/images/m3-serve-textbook-678808c076f31ebd4f2378a1296dd9af.webp" width="1505" height="420" class="img_ev3q"></p>
<p><em>Right: a clean toss, full stretch, on the flagged frame.</em></p>
<p><img decoding="async" loading="lazy" alt="Five-frame strip showing a player swinging a return, wrongly flagged as a serve" src="https://optic-yellow.com/assets/images/m3-serve-fooled-5c5f9f783c250135bf0317d826245ab7.webp" width="1265" height="420" class="img_ev3q"></p>
<p><em>Wrong: the flagged "server" is the returner, off the mark,
mid-swing — every check in version one said yes.</em></p>
<p>Version two clears several checks before it commits — tracked early
and reliably, right distance from center mark and baseline, a genuine
height spike — or it refuses, with reasons attached, instead of
guessing.</p>
<p>Result: 40 of 60 clips get a confident serve call; the other 20 are
refused, on the record — clips that open mid-rally or rejoin
mid-point, plus two long clips too thin on far-player tracking to
trust, including the marathon 49-shot rally from <a class="" href="https://optic-yellow.com/blog/m3-the-cutting-room">the last
post</a>. Spot-checking a sample of the
confident calls by eye: 6 of 8 named the right server, 5 of 8 found a
usable toss moment — honest, not perfect. Geometry alone tops out
here; a serve is, definitionally, the ball leaving the hand and
crossing the net, and once the ball itself is tracked over these
clips, it settles what posture can only guess at.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-scoreboard">The scoreboard<a href="https://optic-yellow.com/blog/m3-static-camera-isnt#the-scoreboard" class="hash-link" aria-label="Direct link to The scoreboard" title="Direct link to The scoreboard" translate="no">​</a></h2>
<table><thead><tr><th>What got checked</th><th>Result</th></tr></thead><tbody><tr><td>Far player, before the fix</td><td>seen in 35% of frames — mostly a ghost</td></tr><tr><td>Far player, after the fix</td><td>seen in 96% of frames — essentially solved</td></tr><tr><td>Near player, throughout</td><td>seen in ~100% of frames — never a problem</td></tr><tr><td>Clips confident enough for a serve call</td><td>40 of 60</td></tr><tr><td>Clips that honestly refused to guess</td><td>20 of 60</td></tr><tr><td>Spot-check: correct server named</td><td>6 of 8</td></tr><tr><td>Spot-check: usable serve moment found</td><td>5 of 8</td></tr></tbody></table>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-takeaway">The takeaway<a href="https://optic-yellow.com/blog/m3-static-camera-isnt#the-takeaway" class="hash-link" aria-label="Direct link to The takeaway" title="Direct link to The takeaway" translate="no">​</a></h2>
<p>A camera that looks perfectly still to a viewer at home can still
wreck a pixel-by-pixel comparison — so before comparing two photos of
the court, make sure they were taken from exactly the same seat.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><p>Alignment: each frame is registered to its clip's first frame using
ECC (Enhanced Correlation Coefficient) — an alignment method —
restricted to pure translation (x/y shift, no rotation/scale).
<code>point_29</code> is the 32px-spike example, now the mandatory first step
before plate-building, background subtraction, or homography
projection. The free trick cashed in this session: frame-minus-median
already IS a player detector, left over from M1's clean-plate trick.
Ghost-plate fix: a 5-clip window (target + 4 nearest temporal
neighbors), each ECC-aligned before median-merging; zero added API
cost, pure numpy.</p><p>Serve detector v1: single heuristic, distance-to-center-mark only.
<code>point_33</code>: clean, correctly-flagged case. <code>point_40</code>: failure case,
flagged "server" is the returner, mid-swing, off-hash, passing every
v1 check anyway. v2: four required gates — (1) early-clip track
coverage, (2) distance to center mark, (3) distance to own baseline,
(4) a genuine local peak in blob height. Any failed gate produces an
explicit <code>no_confident_serve</code> result with reasons attached, not a
forced guess. Refusal log flags cold-opens, mid-point rejoins, and two
long clips with sparse early far-tracks — including the 49-shot rally
from the cutting-room post (cv-06).</p><p>Also queued: fps as a real parameter — this reel runs 25fps against
the original clip's 30fps, and velocity thresholds are secretly
per-frame numbers.</p></div></div></details>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="whats-next">What's next<a href="https://optic-yellow.com/blog/m3-static-camera-isnt#whats-next" class="hash-link" aria-label="Direct link to What's next" title="Direct link to What's next" translate="no">​</a></h2>
<p>Session tally: player positions across all sixty clips, serve details
on two of three, three wrong assumptions caught — for $0.00. Project
total: still about seventy-five cents, all spent in <a class="" href="https://optic-yellow.com/blog/m0-first-tracked-rally">the very first
tracked-rally session</a>. SAM 3, the paid
tracker, has exactly one job left: finding the ball.</p>
<p>Next: a ball detector on these same sixty clips. Then the serve call,
hits, bounces, and shot letters should click into a complete point,
written in the Match Charting Project's own notation — the human-made
<a class="" href="https://optic-yellow.com/primer#ground-truth">answer key</a> this project is chasing. The one
working chart string so far, <a class="" href="https://optic-yellow.com/blog/m3-who-hit-that">from two posts
ago</a>, was just the middle of a rally. The next
one should have a beginning, a middle, and an end.</p>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
        <item>
            <title><![CDATA[M3: The Cutting Room]]></title>
            <link>https://optic-yellow.com/blog/m3-the-cutting-room</link>
            <guid>https://optic-yellow.com/blog/m3-the-cutting-room</guid>
            <pubDate>Thu, 09 Jul 2026 23:45:00 GMT</pubDate>
            <description><![CDATA[Most of a tennis broadcast isn't the match — the software has to spot the one camera angle that actually shows it.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top: sixty point candidates, one after another, cut
automatically out of a 24-minute highlights reel — almost every one
starts on a serve.</em></p>
<p>Watch a tennis broadcast and count how much of it is actually a point
being played. Not much. There's a replay after nearly every big rally, a
coach's-box shot, a close-up between points — actual tennis is a small
slice of the screen. Today's question: can software find, on its own,
only the real points inside a whole reel of broadcast footage? It can —
and it turned up the best plot twist of the project so far.</p>
<p>The project's goal, set on <a class="" href="https://optic-yellow.com/blog/auto-charting-kickoff">day one</a>, is
software that writes the same shot-by-shot notation <a href="https://tennisabstract.com/charting/meta.html" target="_blank" rel="noopener noreferrer" class="">Match Charting
Project</a> volunteers write
by hand — that first post already flagged finding the right clips as its
own job, then four posts put it off. <a class="" href="https://optic-yellow.com/blog/m1-pixels-to-meters">An earlier
session</a> built the conversion from screen
position to court position, matched to the court's own painted lines.
<a class="" href="https://optic-yellow.com/blog/m3-who-hit-that">The most recent session</a> called every forehand
and backhand across a whole rally — but that rally was one 16-second
clip that starts and ends mid-point, with no serve, ending, or score
anywhere in it.</p>
<p>That clip's match — Zverev versus Gasquet, Montreal 2017 — also has a
24-minute <em>extended</em> highlights reel: same broadcast, same camera,
dozens of complete points, cut together with close-ups, crowd shots, and
slow-motion replays. So today's job isn't a tennis question. It's an
editing-room one: out of the reel's 36,234 individual pictures, which
show the real camera, and which are decoration?</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-idea-that-worked-reusing-a-conversion-as-a-fingerprint">The idea that worked: reusing a conversion as a fingerprint<a href="https://optic-yellow.com/blog/m3-the-cutting-room#the-idea-that-worked-reusing-a-conversion-as-a-fingerprint" class="hash-link" aria-label="Direct link to The idea that worked: reusing a conversion as a fingerprint" title="Direct link to The idea that worked: reusing a conversion as a fingerprint" translate="no">​</a></h2>
<p>A tennis broadcast has one main camera — high, behind the baseline,
showing the whole court — the only camera a Match Charting Project
volunteer could chart from. Everything else the director cuts to is a
different camera, and a highlights reel is built almost entirely out of
those cutaways.</p>
<p>The first idea, tried and broken below, was to spot the difference by
color: the court is blue, the ground around it is green. What actually
worked reused something already built: an earlier session's conversion
from screen position to court position, the <strong>homography</strong>, fit once by
matching the court's painted lines to its real dimensions (<a class="" href="https://optic-yellow.com/primer#homography">more on it
here</a>). That conversion doubles as a <strong>camera
fingerprint</strong> — a test for whether a frame was shot from that one
calibrated camera position. Project the true shape of a tennis court
through it onto a new frame: on the main camera, the lines land exactly
on the paint, because the conversion was built for that camera's
position. On any other camera they miss — the projected court floats
off into empty space instead of matching anything real.</p>
<p>Checking a frame becomes checking a few dozen spots: does court-colored
paint sit where the projection says it should, and does
the strip just outside the doubles lines correctly <em>not</em> have court
paint in it? Pass both, it's the main camera. Fail either, it's a
cutaway — including an actual replay, which would otherwise chart the
same point twice.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="attempt-one-paint-colors-and-why-noon-breaks-them">Attempt one: paint colors, and why noon breaks them<a href="https://optic-yellow.com/blog/m3-the-cutting-room#attempt-one-paint-colors-and-why-noon-breaks-them" class="hash-link" aria-label="Direct link to Attempt one: paint colors, and why noon breaks them" title="Direct link to Attempt one: paint colors, and why noon breaks them" translate="no">​</a></h2>
<p>The first classifier measured blue and green in each frame against a
threshold, tuned on frames from late in the match. It looked great —
every accepted frame really was the main camera.</p>
<p>Then came the check nobody skips: looking at what it <em>rejected</em>. Six
frames were obviously real points. All from the first set. All in
blazing daylight.</p>
<p><img decoding="async" loading="lazy" alt="A daytime broadcast frame beside the same frame with its largest blue region highlighted, sprawling across court and stands" src="https://optic-yellow.com/assets/images/m3-blue-merge-fail-73e381c604983e418289d5fe4ecbb357.webp" width="1280" height="360" class="img_ev3q"></p>
<p><em>The blue region found in a daytime frame: the court and the shaded
seating bowl behind it, fused into one shape covering half the picture.</em></p>
<p>Two things were going wrong, both about light. In daylight, the
shaded seats read as the same blue as the court and merge into one giant
shape. At the same time, the sunlit ground around the court washes out
too pale to register as green. <strong>The match starts at noon in full sun
and ends at night under floodlights, and a color rule tuned for one end
breaks at the other.</strong></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-actually-worked-in-numbers">What actually worked, in numbers<a href="https://optic-yellow.com/blog/m3-the-cutting-room#what-actually-worked-in-numbers" class="hash-link" aria-label="Direct link to What actually worked, in numbers" title="Direct link to What actually worked, in numbers" translate="no">​</a></h2>
<p><img decoding="async" loading="lazy" alt="Two reel frames, daytime and evening, each with the projected court outline landing exactly on the court lines" src="https://optic-yellow.com/assets/images/m3-framing-check-f3949bba096c087dc25179978e6b5cab.webp" width="1280" height="360" class="img_ev3q"></p>
<p><em>The projected court outline, laid onto two frames of the new reel —
daytime and evening. Pixel-perfect on both: same broadcast, same
camera.</em></p>
<p>Fifty spots that had to be court-blue, seventeen just outside the
doubles lines that had to not be, checked frame by frame across the
whole reel. Smooth out single-frame flickers, keep only stretches of
three seconds or longer, and the reel breaks into <strong>60 segments, 725
seconds of chartable tennis</strong> — about twelve minutes out of
twenty-four.</p>
<p><img decoding="async" loading="lazy" alt="Twenty tiled frames, each the opening of a detected segment, nearly all showing a serve about to happen" src="https://optic-yellow.com/assets/images/m3-seg-starts-666b51dd8a416c4d4eff122cbe10c664.webp" width="1280" height="900" class="img_ev3q"></p>
<p><em>The first twenty segment openings. The service toss shows up again and
again — highlight editors cut in just before the serve, so it arrives
free with every cut.</em></p>
<p>All sixty openings got checked by eye: every one is a real broadcast
view, every one runs to a dead ball. Broadcast editors, it turns out,
already hand this project exactly the unit it needs: complete points.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="auditing-the-gaps-and-one-honest-miss">Auditing the gaps, and one honest miss<a href="https://optic-yellow.com/blog/m3-the-cutting-room#auditing-the-gaps-and-one-honest-miss" class="hash-link" aria-label="Direct link to Auditing the gaps, and one honest miss" title="Direct link to Auditing the gaps, and one honest miss" translate="no">​</a></h2>
<p>Every long stretch between segments got checked too, turning up twenty
genuine cutaways — including sideline and net-camera <em>replays</em>, failing
the same way.</p>
<p>One gap was a real loss.</p>
<p><img decoding="async" loading="lazy" alt="Side-by-side comparison of the standard framing with the outline fitting and an early-match tighter framing where the outline floats off the lines" src="https://optic-yellow.com/assets/images/m3-zoom-drift-fa51aad0cac24a3f602df4625f0168f0.webp" width="1280" height="360" class="img_ev3q"></p>
<p><em>Early in set one the broadcast used a tighter zoom. The court still
read correctly inside, but the bigger court filling the frame swallowed
the outside-the-lines check. 131 seconds of real tennis, rejected.</em></p>
<p>The "camera never moves" assumption broke for those two minutes — a
known hole, on the record. The fix, if needed: re-fit the conversion per
stretch instead of once for the whole reel, which the earlier
conversion work can already do automatically.</p>
<p><img decoding="async" loading="lazy" alt="Timeline strip of the whole reel: green segments of court view, gray cutaways, one hatched orange block for the missed zoom section" src="https://optic-yellow.com/assets/images/m3-point-timeline-289e3c9eaa492b38898614cd2bf2b6d3.webp" width="2100" height="390" class="img_ev3q"></p>
<p><em>Twenty-four minutes of reel: 60 green segments found, one orange
confession.</em></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-twist-buried-in-segment-51">The twist buried in segment 51<a href="https://optic-yellow.com/blog/m3-the-cutting-room#the-twist-buried-in-segment-51" class="hash-link" aria-label="Direct link to The twist buried in segment 51" title="Direct link to The twist buried in segment 51" translate="no">​</a></h2>
<p>All sixty candidate clips went to disk. Segment 51 stood out
immediately: <strong>59.6 seconds of unbroken main-camera footage, by far the
longest of the sixty.</strong></p>
<p>That's the point this match is famous for — a 49-shot rally, the one
that shows up in YouTube video titles. The very first clip <a class="" href="https://optic-yellow.com/blog/m0-first-tracked-rally">this project
was built on</a> turns out to be a 16-second
slice from the <em>middle</em> of that same rally. The segment
finder, which knows nothing about tennis and only checks colors and
lines, found the entire point on its own — serve included.</p>
<p><strong>Every session so far has been charting an excerpt of a much longer
story. It just got handed the whole thing.</strong></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-scoreboard">The scoreboard<a href="https://optic-yellow.com/blog/m3-the-cutting-room#the-scoreboard" class="hash-link" aria-label="Direct link to The scoreboard" title="Direct link to The scoreboard" translate="no">​</a></h2>
<table><thead><tr><th>What was checked</th><th>Result</th></tr></thead><tbody><tr><td>Frames sorted, real point or not</td><td>36,234</td></tr><tr><td>Real point segments found</td><td>60, about 12 minutes of footage</td></tr><tr><td>Segment openings checked by eye</td><td>60 of 60 correct</td></tr><tr><td>Genuine cutaways caught, replays included</td><td>20</td></tr><tr><td>Footage lost to a camera-zoom gap</td><td>131 seconds</td></tr><tr><td>Longest segment found</td><td>59.6 seconds — the match's famous 49-shot rally</td></tr></tbody></table>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-takeaway">The takeaway<a href="https://optic-yellow.com/blog/m3-the-cutting-room#the-takeaway" class="hash-link" aria-label="Direct link to The takeaway" title="Direct link to The takeaway" translate="no">​</a></h2>
<p>A tennis broadcast spends most of its time showing you everything except
the tennis, so before software can chart a single point, it first has to
find the one camera actually pointed at it.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><p>Source: same Zverev–Gasquet Montreal 2017 broadcast, 24-minute extended
highlights reel, 36,234 frames.</p><p>v1: blue-fraction / green-fraction thresholds on HSV, tuned on
late-match frames. High precision, low recall — 6 rejected frames from
set one (daylight) were true positives. Two simultaneous causes: (1)
shaded seating bowl reads as court-hue in daylight and merges into a
single ~50%-of-frame contour; (2) sunlit apron washes out to saturation
38 vs. threshold 40.</p><p>v2: reuse the homography fit from the original 16-second rally clip as a
pose check — project the court model's corners onto every reel frame.
On-camera frames land pixel-perfect, daytime and evening. 50 interior
sample points must read court-blue, 17 points just outside the doubles
alleys must not. Boolean per frame, median-smoothed, segments trimmed to
runs ≥3s. Result: 60 segments, all starts/ends manually verified.</p><p>Audit of inter-segment gaps: 20 confirmed cutaways, including
sideline/net-cam replays (fail cleanly — different camera pose). One
false negative: early-set tighter zoom (131s), interior probes still hit
but the enlarged court swallowed the apron-margin probes. Not yet fixed:
refit homography per block rather than globally; the fitting step is
already automatic.</p><p>Plot twist: segment 51 = 59.6s, the match's 49-shot rally (its
YouTube-title fame). The original tracked-rally clip is a 16-second
excerpt from its middle.</p><p>Session cost: $0.00 — HSV thresholding + 67 pixel probes/frame (50
interior + 17 exterior). Project total: ~$0.75.</p><p>Open issue: reel is 25fps; the hit/bounce detector was tuned at 30fps,
and its velocity thresholds are secretly per-frame deltas, not
per-second — fps needs to become an explicit parameter before that
detector runs on these clips.</p><p>Chart state: <code>?f2f1f1b2f2f2b??</code> — leading <code>?</code> = unknown serve zone,
seven letter+number pairs (<code>f2 f1 f1 b2 f2 f2 b?</code>) = shots called last
session (shot 7's direction still open), trailing <code>?</code> = unknown ending
code.</p></div></div></details>
<p>This session cost nothing — no AI calls, just color math and pixel
checks. Project total: still about seventy-five cents. One thing to fix
before next time: this reel plays at 25 pictures a second, while the
hit-and-bounce detector was built on a clip that ran at 30, and its
speed cutoffs quietly assume that number.</p>
<p>Every blank left in the chart — the serve, the last shot's direction,
how the point ends — now lives inside these sixty clips, with a 49-shot
monster in the middle waiting to stress-test everything downstream.
Next: point anatomy — finding the serve inside a segment, and the ending
after the last bounce.</p>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
        <item>
            <title><![CDATA[M3: Who Hit That?]]></title>
            <link>https://optic-yellow.com/blog/m3-who-hit-that</link>
            <guid>https://optic-yellow.com/blog/m3-who-hit-that</guid>
            <pubDate>Thu, 09 Jul 2026 23:00:00 GMT</pubDate>
            <description><![CDATA[The software found the players by noticing who never moved, then called every forehand and backhand from where the ball crossed their body.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top: both players outlined and tracked, FOREHAND or BACKHAND
called out at every swing in the hitter's color, feet moving live on the
court diagram — and along the bottom, the shot-by-shot notation typing
itself out.</em></p>
<p>Call a shot forehand or backhand and you're answering a geometry
question: which side of the hitter's body did the ball come off? You
can't answer that without knowing where the player was standing — and
until now, this project had tracked the ball but never found the
players. Too much for one sitting: session one of several, starting
with finding the players.</p>
<p>Here's what came before. This project's whole bet: feed it broadcast
tennis video, get back the exact notation <a href="https://tennisabstract.com/charting/meta.html" target="_blank" rel="noopener noreferrer" class="">Match Charting
Project</a> volunteers
hand-write for real matches — the <a class="" href="https://optic-yellow.com/blog/auto-charting-kickoff">kickoff
post</a> has the pitch. <a class="" href="https://optic-yellow.com/blog/m0-first-tracked-rally">Session
one</a> tracked the game ball through a
rally for about sixty cents, once shown a box around it. Next, the
<a class="" href="https://optic-yellow.com/primer#homography">homography</a> — read off the <a class="" href="https://optic-yellow.com/blog/m1-pixels-to-meters">court's painted
lines</a> — turns a TV-picture spot into a
court-diagram spot, except anything airborne, which converts to its
shadow's position instead. Then <a class="" href="https://optic-yellow.com/blog/m2-hits-and-bounces">hit-and-bounce
detection</a> read events out of kinks in the
ball's path — a hit reverses it, a bounce doesn't, <a class="" href="https://optic-yellow.com/primer#hits-and-bounces">the court doesn't
return serve</a> — producing the first rough
chart: <code>??2?1?1?2?2?2???</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="spot-the-player-by-spotting-what-never-changes">Spot the player by spotting what never changes<a href="https://optic-yellow.com/blog/m3-who-hit-that#spot-the-player-by-spotting-what-never-changes" class="hash-link" aria-label="Direct link to Spot the player by spotting what never changes" title="Direct link to Spot the player by spotting what never changes" translate="no">​</a></h2>
<p>Here's the trick, older than computer vision — it's just averaging.
Stack dozens of photos of the same stretch of court, one per frame. Add
up how often each spot is occupied: spots that barely change are the
court itself, net and lines and grass; spots that keep changing are
something moving through the scene.</p>
<p>Treat the barely-changing version as a stand-in photo of an empty court,
compare every real frame to it, and whatever's different is a player.
That's <strong>background subtraction</strong>. (<a class="" href="https://optic-yellow.com/primer#background-subtraction">More in the
primer.</a>) This project already ran the
same trick once, to erase the players and get a clean court photo for
<a class="" href="https://optic-yellow.com/blog/m1-pixels-to-meters">measuring its lines</a> — today, in reverse.</p>
<p>The catch: the trick only knows "moves" or "doesn't move." Stand still
long enough on camera and you get folded into "the court" — a line
judge at parade rest for a whole rally erases himself as completely as
the baseline.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="two-boxes-one-blob">Two boxes, one blob<a href="https://optic-yellow.com/blog/m3-who-hit-that#two-boxes-one-blob" class="hash-link" aria-label="Direct link to Two boxes, one blob" title="Direct link to Two boxes, one blob" translate="no">​</a></h2>
<p>The plan: draw a box around each player in one frame and let SAM 3 —
the promptable "find that object" AI from <a class="" href="https://optic-yellow.com/primer#wasb-vs-sam">the
primer</a> — track each box through the clip, same
trick that caught the game ball in <a class="" href="https://optic-yellow.com/blog/m0-first-tracked-rally">session
one</a>.</p>
<p><img decoding="async" loading="lazy" alt="Frame 240 with tight labeled boxes drawn around both players, the line judge visibly outside the far box" src="https://optic-yellow.com/assets/images/m3-prompt-boxes-8c6e3ddca5533148fb90d30fb08b5b64.webp" width="1280" height="720" class="img_ev3q"></p>
<p><em>The two boxes, drawn by eye and checked before spending real money. The
line judge, in red, stands just outside the far box — deliberately left
out.</em></p>
<p>Two boxes went in. One came back — a single rectangle per frame, the
union of both players, stretched wide enough to swallow the line judge
behind Gasquet too. Same lesson as session one: "tennis ball" once
matched every ball; now two boxes merged into one.</p>
<p>Useless boxes, but the response also carries the raw shape data behind
them — an outline of what the AI thinks belongs to something. Split it
apart wherever it stops touching itself, and every frame resolves into
three shapes: Zverev, Gasquet, the line judge.</p>
<p>Two of those are players. Finding the third is background subtraction
again: across the rally, add up how often each spot is covered by a
shape, as a percentage. Anything covered more than 85% of the time
belongs to someone who never left — the judge, at parade rest
throughout. Subtract it, and two players are left.</p>
<p><img decoding="async" loading="lazy" alt="Broadcast frame with the line judge&amp;#39;s silhouette solid red, labeled as static" src="https://optic-yellow.com/assets/images/m3-static-judge-4a92e1a3770ad4a7cb4dbe02ad685ab3.webp" width="1120" height="416" class="img_ev3q"></p>
<p><em>The one figure the AI outlined that never moved. Noticing that is the
entire trick for telling a line judge from a player.</em></p>
<p>He erased himself. Nobody taught the software what a line judge looks
like. The only rule: if it doesn't move, it isn't playing.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="both-players-the-whole-rally-no-gaps">Both players, the whole rally, no gaps<a href="https://optic-yellow.com/blog/m3-who-hit-that#both-players-the-whole-rally-no-gaps" class="hash-link" aria-label="Direct link to Both players, the whole rally, no gaps" title="Direct link to Both players, the whole rally, no gaps" translate="no">​</a></h2>
<p>What's left splits into near player and far player, by which half of
the frame each shape's feet land in.</p>
<p><img decoding="async" loading="lazy" alt="Two stacked scatter plots of foot x and foot y per frame for both players, dense and continuous" src="https://optic-yellow.com/assets/images/m3-players-traj-44a04497707705d3dd466f2d563db905.webp" width="2100" height="1200" class="img_ev3q"></p>
<p><em>Both players, every frame of the rally. The two short flat patches
around frames 390 and 425 are the tracker briefly wandering onto
another far-side figure, both after the rally had ended, but on the
record.</em></p>
<p>Result: both players tracked across all 480 frames, zero gaps. The ball
itself, tracked in session one, only managed 470 of 480, with ten
dropouts — easier for this AI than a small, fast ball.</p>
<p>Small twist on an old caveat: the homography only tells the truth for
things touching the ground — an airborne ball converts to its shadow's
position, not its own. Feet are always on the ground, so a player's
position comes out exact, every frame. The most trustworthy dots in
this project belong to the players now, not the ball.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="who-hit-that">Who hit that?<a href="https://optic-yellow.com/blog/m3-who-hit-that#who-hit-that" class="hash-link" aria-label="Direct link to Who hit that?" title="Direct link to Who hit that?" translate="no">​</a></h2>
<p>With both players placed, deciding who struck a ball has three checks:
which player was closest at the hit; which end of the court it was
struck from, the same read used last session for hits and bounces; and
the simple fact that a rally alternates — whoever didn't hit last, hits
now. All three agreed, on all seven hits — three signals nobody tuned
for this question, landing on the same answer anyway.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="forehand-or-backhand-its-just-geometry">Forehand or backhand? It's just geometry<a href="https://optic-yellow.com/blog/m3-who-hit-that#forehand-or-backhand-its-just-geometry" class="hash-link" aria-label="Direct link to Forehand or backhand? It's just geometry" title="Direct link to Forehand or backhand? It's just geometry" translate="no">​</a></h2>
<p>Once a player's position is trustworthy, calling the shot needs no more
AI — it's geometry. Ball on the right of a right-hander at contact is a
forehand, on the left a backhand — the same call from the couch.</p>
<p>One wrinkle: the camera-facing player has left and right flipped, so
his measurement gets mirrored. One liability: handedness is assumed,
not detected — a left-handed player would get every call flipped, a
debt the series collects later.</p>
<p>The verdict: forehand, forehand, forehand, backhand, forehand,
forehand, backhand — seven shots, seven calls, checked by pulling the
video frame for every swing, the eyes-over-math discipline that saved
<a class="" href="https://optic-yellow.com/blog/m2-hits-and-bounces">last session</a>.</p>
<p><img decoding="async" loading="lazy" alt="Five-frame zoomed strip of Gasquet&amp;#39;s one-handed backhand at frame 285, racket coiled high then swept through" src="https://optic-yellow.com/assets/images/m3-verify-backhand-553fc32eb53eb9b4783a017fed46eb66.webp" width="1700" height="464" class="img_ev3q"></p>
<p><em>Shot seven, the rally's last swing: Gasquet's one-handed backhand,
racket coiled high before he sweeps through it. If you're going to
eyeball-check a backhand, make it his.</em></p>
<p>Seven for seven — but one deserves an asterisk. On shot three, the ball
crossed so close to Gasquet's body that the measurement came out just
1.5 pixels to the forehand side — a coin-flip margin. The video settles
it: a low pickup off his right shoe, unmistakably a forehand. Right,
but only because the coin landed the correct way up.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-chart-gets-its-letters">The chart gets its letters<a href="https://optic-yellow.com/blog/m3-who-hit-that#the-chart-gets-its-letters" class="hash-link" aria-label="Direct link to The chart gets its letters" title="Direct link to The chart gets its letters" translate="no">​</a></h2>
<p>The two versions of this rally's chart, side by side:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">before:  ??2?1?1?2?2?2???</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">after:   ?f2f1f1b2f2f2b??</span><br></span></code></pre></div></div>
<p>Read the finished string left to right:</p>
<ul>
<li class="">The first <code>?</code> is the serve — unknown, since this clip never shows
one.</li>
<li class=""><code>f2 f1 f1 b2 f2 f2 b</code> are the seven shots, in order: forehand,
forehand, forehand, backhand, forehand, forehand, backhand — this
session's addition. The digits (settled last session) mark which
third of the court each shot crossed into. Shot seven has no digit —
it ends the point, coded differently.</li>
<li class="">The last two <code>?</code> marks are where the ball landed and how the point
ended — also missing from this clip.</li>
</ul>
<p>The chart also gained something the earlier version couldn't show:
where each player was standing when they hit.</p>
<p><img decoding="async" loading="lazy" alt="Top-down court with each shot drawn as an arrow from the striker&amp;#39;s position to the landing spot, labeled with shot number and letter" src="https://optic-yellow.com/assets/images/m3-shot-map-579a65d101101cd5079501dc29c3c79d.webp" width="900" height="1500" class="img_ev3q"></p>
<p><em>Seven arrows — who hit it, which wing, from where, to where. Dashed
arrows land on the far side, still accurate only to within a meter or
so.</em></p>
<p>Zverev played right around his baseline; Gasquet, more than three
meters behind his — court positioning anyone who's watched him play
would recognize.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-scoreboard">The scoreboard<a href="https://optic-yellow.com/blog/m3-who-hit-that#the-scoreboard" class="hash-link" aria-label="Direct link to The scoreboard" title="Direct link to The scoreboard" translate="no">​</a></h2>
<table><thead><tr><th>What we checked</th><th>What happened</th><th>What "good" looks like</th></tr></thead><tbody><tr><td>Both players tracked, every frame</td><td>480 of 480 frames, zero gaps</td><td>The ball itself only managed 470 of 480, with ten short dropouts</td></tr><tr><td>Who hit each shot, three ways</td><td>All three signals agreed, on all seven hits</td><td>Independent checks landing on the same answer, unprompted</td></tr><tr><td>Forehand vs. backhand, checked by eye</td><td>7 of 7 correct</td><td>Every one of the seven swings verified against actual video</td></tr></tbody></table>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-takeaway">The takeaway<a href="https://optic-yellow.com/blog/m3-who-hit-that#the-takeaway" class="hash-link" aria-label="Direct link to The takeaway" title="Direct link to The takeaway" translate="no">​</a></h2>
<p>Stand still long enough on a tennis court, and even a computer starts
to think you're part of the furniture.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><ul>
<li class="">
<p><strong>The box-merge failure.</strong> SAM 3's two-box prompt returned one
unioned bounding box per frame, not two — same "prompts fuse into one
concept" behavior as the text prompts in session one; per-object
structure doesn't survive a single API call. The box's right edge
landed at roughly pixel 807, exactly far enough to include the line
judge. The response does include the raw run-length-encoded (RLE)
masks — space-separated start/run pairs over the flattened frame,
undocumented like the box format. Decoded and split into connected
components, each frame resolves into three spatially disjoint blobs
(Zverev, Gasquet, line judge); nobody occludes anybody else in image
space.</p>
</li>
<li class="">
<p><strong>The static filter.</strong> For every pixel, the fraction of the 480
frames it's covered by any mask (<code>np.mean</code> over the stacked binary
masks, time axis). Anything "on" over 85% of the time is treated as
static and subtracted from every frame's mask before splitting the
rest into players — the same averaging that built the player-free
court photo for the homography, run in reverse.</p>
</li>
<li class="">
<p><strong>The forehand/backhand formula.</strong> Shot type = sign of (ball
x-position minus striker's blob-center x-position) at contact,
mirrored for the far player. Handedness is assumed, not detected.
Shot 3's raw offset was −1.5 pixels — inside the noise floor of a
single pixel of foot-tracking error, essentially arbitrary before
video confirmed it; needs a confidence threshold and a bodyline
policy before an untuned rally. Position precision differs by side:
near-side (Zverev, whose three shots came from 0 to 1.3 meters behind
his baseline) converts with high confidence; far-side (Gasquet) is in
meters-per-pixel territory, so his "more than three meters behind the
baseline" is directionally solid, not precise.</p>
</li>
<li class="">
<p><strong>Session accounting.</strong> One paid API call (the two-box SAM 3 prompt);
everything after ran locally at no cost.</p>
</li>
</ul></div></div></details>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="cost-and-the-next-gap">Cost, and the next gap<a href="https://optic-yellow.com/blog/m3-who-hit-that#cost-and-the-next-gap" class="hash-link" aria-label="Direct link to Cost, and the next gap" title="Direct link to Cost, and the next gap" translate="no">​</a></h2>
<p>Session cost: $0.15 — one API call, everything after it just
arithmetic on numbers already in hand. Running total: about
seventy-five cents.</p>
<p>The remaining question marks didn't move — the serve, where the ball
landed, how the point ended, all still blank, since a mid-rally
highlight can't show a point starting or ending. Next session: better
source footage — full match video, complete points, camera cuts and
all.</p>
<p>The chart still reads <code>?f2f1f1b2f2f2b??</code> — seven question marks fewer
than it started with, three still to go. Time to earn the rest of the
punctuation.</p>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
        <item>
            <title><![CDATA[M2: The Court Doesn't Return Serve]]></title>
            <link>https://optic-yellow.com/blog/m2-hits-and-bounces</link>
            <guid>https://optic-yellow.com/blog/m2-hits-and-bounces</guid>
            <pubDate>Thu, 09 Jul 2026 21:00:00 GMT</pubDate>
            <description><![CDATA[Teaching software to spot every hit and bounce in a rally, just from watching the ball's path bend.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top: the pipeline calling out HIT and BOUNCE in real time,
pinning every one onto a court diagram the instant it happens.</em></p>
<p>The last post ended with a warning that turned into today's homework: the
system can only trust the ball's screen position at the exact moments it's
touching the court — everything else is guesswork. So today's question:
watching nothing but the ball's path across the screen, can software find
every hit, and every bounce?</p>
<p>The detector I built today ended up calling all thirteen of those moments
correctly. It got there by being wrong twice first, and both mistakes
taught me more about how a tennis ball moves than the successes did.</p>
<p><strong>Previously:</strong> <a class="" href="https://optic-yellow.com/blog/m1-pixels-to-meters">last time</a>, the software
learned to convert "where the ball is on your TV screen" into "where the
ball is on the actual court" — the same trick a fan does by eye, guessing
where a shot landed. The catch, carried straight into today: that
conversion only tells the truth for things touching the ground. A ball in
mid-air converts to the position of its <em>shadow</em>, not the ball itself. So
the software needs to know exactly when the ball is on the ground before
it can trust anything else it says.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="reading-the-rally-from-the-shape-of-the-path">Reading the rally from the shape of the path<a href="https://optic-yellow.com/blog/m2-hits-and-bounces#reading-the-rally-from-the-shape-of-the-path" class="hash-link" aria-label="Direct link to Reading the rally from the shape of the path" title="Direct link to Reading the rally from the shape of the path" translate="no">​</a></h2>
<p>A ball in a rally does exactly two things, over and over, and they look
nothing alike in its path.</p>
<p>When a player <strong>hits</strong> the ball, it reverses direction — back the way it
came, sharp and immediate. When the ball <strong>bounces</strong>, nothing reverses. It
keeps traveling the same way, just lower and slower. A racket sends the
ball backward; the court just takes some steam off it and lets it keep
going. That's the whole idea in one line: <strong>the court doesn't return
serve.</strong> (More on reading events out of a flight path: <a class="" href="https://optic-yellow.com/primer#hits-and-bounces">Court Vision in
Plain English</a>.)</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-the-balls-path-actually-looks-like">What the ball's path actually looks like<a href="https://optic-yellow.com/blog/m2-hits-and-bounces#what-the-balls-path-actually-looks-like" class="hash-link" aria-label="Direct link to What the ball's path actually looks like" title="Direct link to What the ball's path actually looks like" translate="no">​</a></h2>
<p>First move: turn the tracked dots into a graph of position and speed over
time, and look at it.</p>
<p><img decoding="async" loading="lazy" alt="Three stacked plots: image y-position, its velocity, and along-court shadow velocity across the rally&amp;#39;s 300 frames" src="https://optic-yellow.com/assets/images/m2-velocity-structure-ba07189b654207d4df67731c483d9a8c.webp" width="1600" height="1143" class="img_ev3q"></p>
<p><em>The ball's up-down screen position, how fast that's changing, and how
fast its shadow moves lengthwise down the court — across the rally's 290
usable frames (each a single still picture — more in <a class="" href="https://optic-yellow.com/primer#pixels-and-frames">Pixels and
frames</a>), with 10 short gaps where the tracker
briefly lost the ball.</em></p>
<p>Two traps, both from camera angle. First: a ball's up-down position on
screen isn't the same as its real height — a ball at the far baseline
still looks "high" simply because it's small and distant on screen — the
pretty arcs you'd expect a bounce to draw aren't real; they're height and
distance tangled together. Second, and stranger: the shadow's sideways
speed spikes to the equivalent of 200 meters per second when the ball is
high on the far side. Nothing on court moves that fast — it's the camera
exaggerating airborne motion.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="first-attempt-eight-hits-one-bounce">First attempt: eight hits, one bounce<a href="https://optic-yellow.com/blog/m2-hits-and-bounces#first-attempt-eight-hits-one-bounce" class="hash-link" aria-label="Direct link to First attempt: eight hits, one bounce" title="Direct link to First attempt: eight hits, one bounce" translate="no">​</a></h2>
<p>My first version found every cusp in the ball's up-down speed — where it
stops falling — then called hit or bounce by whether direction flipped.
It found nine such moments and called eight of them hits.</p>
<p>That's not how tennis works — almost every stroke is preceded by a
bounce. Something was badly wrong, and the fix wasn't better math. It was
doing what a chair umpire does with a disputed call: stop arguing and
<strong>go look at the replay.</strong> I pulled the actual video frames around every
flagged moment.</p>
<p><img decoding="async" loading="lazy" alt="Three broadcast frames around frame 44: the ball descending toward Zverev, at his feet, then rising as he winds up" src="https://optic-yellow.com/assets/images/m2-verify-strip-2b4a8b4beebd4c7f9a20fc3e989fcdaf.webp" width="1600" height="300" class="img_ev3q"></p>
<p><em>Frame 44, flagged as a hit. The ball is at Zverev's feet; his racket is
nowhere near it. That's a bounce.</em></p>
<p>Watching those frame strips built a hand-verified answer key for the
rally, and exposed two ways the detector was fooling itself.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="two-failures-two-fixes">Two failures, two fixes<a href="https://optic-yellow.com/blog/m2-hits-and-bounces#two-failures-two-fixes" class="hash-link" aria-label="Direct link to Two failures, two fixes" title="Direct link to Two failures, two fixes" translate="no">​</a></h2>
<p><strong>Near-side bounces looked like hits.</strong> Right after a real bounce, the
shadow speed collapses into a small, noisy number whose sign flips often
enough to trick the direction test. Fix: stop asking whether direction
flipped; ask how fast the ball leaves instead. A real return leaves fast;
a bounce's outgoing speed collapses toward zero.</p>
<p><strong>Far-side bounces were invisible, not just hard.</strong> After a far bounce,
the ball rises and recedes at once — both push its screen position the
same way, so there's no cusp to find at all. What does exist: the same
shadow-speed exaggeration from before. Airborne, the far shadow races at
wildly inflated speed; the instant the ball lands, that exaggeration
vanishes and the shadow slows near the truth. That collapse marks the
bounce, even where nothing in the picture looks like one.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-false-alarm-that-taught-the-most">The false alarm that taught the most<a href="https://optic-yellow.com/blog/m2-hits-and-bounces#the-false-alarm-that-taught-the-most" class="hash-link" aria-label="Direct link to The false alarm that taught the most" title="Direct link to The false alarm that taught the most" translate="no">​</a></h2>
<p>The first version of that collapse-detector immediately flagged a moment
mid-rally as a bounce. The video showed the ball sailing over the net, a
meter and a half up — nowhere near bouncing.</p>
<p>It took a minute to understand: the collapse isn't instant at ground
contact — it unwinds gradually through the descent, fading as the ball
loses height. An early "slowing down" is just the ball still falling.
<strong>The bounce is the last collapse of a descent, not the first.</strong></p>
<p>Re-checking frames after that fix surfaced one more find, and it wasn't
about the ball: my own hand-built answer key had quietly stopped early,
before the rally actually ended. The tracked data ran further — one more
far bounce, then Gasquet's final swing, both missed on my first pass. Even
a hand-checked answer key needs its own second check.</p>
<p><img decoding="async" loading="lazy" alt="The rally trajectory with all thirteen events marked and labeled: red dots for hits, white for bounces" src="https://optic-yellow.com/assets/images/m2-events-trajectory-a59188832f8c235af28c0e7812b1c038.webp" width="1600" height="572" class="img_ev3q"></p>
<p><em>The finished detector: seven hits, six bounces, all thirteen matching the
video.</em></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-payoff">The payoff<a href="https://optic-yellow.com/blog/m2-hits-and-bounces#the-payoff" class="hash-link" aria-label="Direct link to The payoff" title="Direct link to The payoff" translate="no">​</a></h2>
<p>Thirteen events found, checked against video, correctly told apart as hit
or bounce — zero wrong calls. Because the screen-to-court conversion
already exists, each one drops onto an actual scale drawing of the court:</p>
<p><img decoding="async" loading="lazy" alt="Top-down court with all thirteen events placed: solid yellow bounce dots on the near side, hollow ones on the far side, red X marks for hits at both ends" src="https://optic-yellow.com/assets/images/m2-event-map-2535f60d580cb0aae9e571ff59b39db2.webp" width="900" height="1500" class="img_ev3q"></p>
<p><em>Thirteen dots and X's on a court diagram. Solid yellow (near-side
bounces) are trustworthy to within centimeters. Hollow dots (far-side)
are only good to within a few meters — the same camera-angle problem as
above.</em></p>
<p>The near-side bounces landed at 19.4, 19.6, and 20.4 meters down the court
— about three and a half meters inside the baseline, exactly where pro
groundstrokes land. Nobody told the software that; it just fell out of the
math.</p>
<p>Read the sequence out loud: <em>hit, bounce, hit, bounce, hit</em> — a rally,
described shot by shot. No shot types or directions yet, but for the
first time this reads like the start of a match chart, not a vision demo.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-scoreboard">The scoreboard<a href="https://optic-yellow.com/blog/m2-hits-and-bounces#the-scoreboard" class="hash-link" aria-label="Direct link to The scoreboard" title="Direct link to The scoreboard" translate="no">​</a></h2>
<table><thead><tr><th>What was checked</th><th>Result</th></tr></thead><tbody><tr><td>Events found in the rally</td><td>13 (7 hits, 6 bounces)</td></tr><tr><td>Correctly told apart, hit vs. bounce</td><td>13 of 13</td></tr><tr><td>Wrong calls</td><td>0</td></tr><tr><td>Near-side bounce landing spots</td><td>within centimeters of real groundstroke depth</td></tr><tr><td>Far-side bounce landing spots</td><td>good only to within a few meters</td></tr></tbody></table>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-takeaway">The takeaway<a href="https://optic-yellow.com/blog/m2-hits-and-bounces#the-takeaway" class="hash-link" aria-label="Direct link to The takeaway" title="Direct link to The takeaway" translate="no">​</a></h2>
<p>A hit throws the ball back where it came from; a bounce just lets it keep
going, a little slower. That one difference — reversed or not — is enough
to read a whole rally's worth of events out of a wobbly, camera-distorted
flight path. The court doesn't return serve.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><p>Differentiated the M0 track: 290 usable frames, 10 small gaps. Two traps:
image-y mixes height with court depth (M0's "parabolas" are projection
mixtures); along-court shadow velocity spikes to ±200 m/s when the ball is
high on the far side.</p><p>Detector v1: cusps in vertical velocity, classified by direction flip.
Found 9 events, called 8 hits. Root-caused by pulling video frames at
every event rather than debugging further — frame 44 flagged as a hit
showed the ball at Zverev's feet, racket nowhere near it.</p><p>Failure 1, near-side bounces classified as hits: post-bounce shadow
velocity collapses into noise (-2 to -4 m/s) and its sign flips, fooling
the direction test. Fix: classify on outgoing speed, not sign — real
returns leave at 5+ m/s (often 40); bounces collapse toward zero.</p><p>Failure 2, far-side bounces structurally invisible: post-bounce the ball
rises and recedes simultaneously, both pushing image position the same
way — no cusp exists in that coordinate. Fix: shadow-speed collapse —
airborne shadow runs -30 to -50 m/s of amplified nonsense, on-ground
shadow runs true speed (~-5).</p><p>False positive at frame 71: collapse detector v1 flagged it as a bounce;
video shows the ball crossing the net, 1.5m up. Root cause: collapse
unwinds gradually through the descent — early collapse candidates are
descent-in-progress, not landing. Fix: bounce = <em>last</em> collapse of a
descent, not first.</p><p>While re-verifying, found my own ground truth had stopped at frame 243
while the track runs to ~298 — one more far bounce, then Gasquet's final
swing, found by the detector, missed by the manual labels.</p><p>Detector v4 (final): 7 hits, 6 bounces, 13/13 matched against
frame-verified ground truth, 13/13 classified correctly, 0 false
positives. Near-side bounce depths: 19.4, 19.6, 20.4m — ~3.5m inside the
baseline, consistent with pro groundstroke depth.</p><p>Caveats: thresholds tuned to one clip, generalization untested. Far-side
positions good only to a few meters — at the far baseline, one frame of
timing error is meters of shadow, and the ball is already moving again by
the next frame. Near-side positions look genuinely good.</p></div></div></details>
<p>Running total across the first three builds — an object tracker, a
screen-to-court conversion, a frame-verified event detector — is still 60
cents, all spent on the first one. The other two were just math run over
data already collected.</p>
<p>Next: turning this sequence of events into an actual chart — splitting
footage into rallies, figuring out shot direction, and writing it all out
in the Match Charting Project's own notation. That's the point where this
stops being a computer-vision project and starts being a tennis project.</p>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
        <item>
            <title><![CDATA[M1: From Pixels to Meters]]></title>
            <link>https://optic-yellow.com/blog/m1-pixels-to-meters</link>
            <guid>https://optic-yellow.com/blog/m1-pixels-to-meters</guid>
            <pubDate>Thu, 09 Jul 2026 18:00:00 GMT</pubDate>
            <description><![CDATA[How four painted corners taught the software to turn a TV picture of the court into a real, to-scale court diagram — for free.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top: the yellow box tracking the ball on the broadcast
picture, next to the same rally redrawn as dots on a plain, top-down
court diagram — the ball's real position, in meters, drawn live as the
rally plays out.</em></p>
<p>Last time, the software could hold onto a tennis ball for an entire
rally on screen. Today's question: can it tell you whether that ball
landed in the court, or a foot wide of it?</p>
<p><strong>Previously:</strong> the <a class="" href="https://optic-yellow.com/blog/auto-charting-kickoff">kickoff post</a> set the
goal — teach software to watch a broadcast match and produce the
point-by-point notes volunteers hand-type for the <a href="https://tennisabstract.com/charting/meta.html" target="_blank" rel="noopener noreferrer" class="">Match Charting
Project</a>, a slow,
human-powered dataset the project wants to speed up. <a class="" href="https://optic-yellow.com/blog/m0-first-tracked-rally">Last
time</a>, an AI followed one specific ball
through a whole rally for about sixty cents. What came back was a list
of screen positions — pixel coordinates, the colored dots a video frame
is built from (more in <a class="" href="https://optic-yellow.com/primer#pixels-and-frames">Court Vision in Plain
English</a>). A screen position alone can't say
whether a shot landed in or well wide.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="turning-a-tv-picture-into-a-court-diagram">Turning a TV picture into a court diagram<a href="https://optic-yellow.com/blog/m1-pixels-to-meters#turning-a-tv-picture-into-a-court-diagram" class="hash-link" aria-label="Direct link to Turning a TV picture into a court diagram" title="Direct link to Turning a TV picture into a court diagram" translate="no">​</a></h2>
<p>You already do this without noticing. Watch a match on TV, and when a
ball lands, you can point to roughly the right spot on a paper court
diagram — even though the camera sits up high behind the baseline, so
the far side of the court looks small and squeezed while the near side
looms huge. Your brain quietly un-squeezes it. That un-squeezing rule —
turning "this spot on the TV screen" into "this spot on the court" — is
what computer vision calls a <strong>homography</strong>, the screen-to-court-diagram
conversion (<a class="" href="https://optic-yellow.com/primer#homography">more on it here</a>). Software needs the
recipe spelled out, and it gets one the way a chair umpire settles an
argument: from the court's own painted lines. Match what the camera sees
to the real dimensions — every court on earth is 10.97 by 23.77 meters —
and the conversion falls out.</p>
<p>One catch to tuck away first: this recipe only works for things sitting
<strong>on the ground</strong>. It converts the court's flat surface, not the air
above it. A player's feet are on the ground, so their position converts
honestly. A ball in flight isn't touching the ground, so the same recipe
reports where the ball's <em>shadow</em> would be — not the ball. Hold onto
that; it's the twist waiting at the end of this post, and it echoes
through the rest of the series.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="getting-an-empty-court-for-free">Getting an empty court for free<a href="https://optic-yellow.com/blog/m1-pixels-to-meters#getting-an-empty-court-for-free" class="hash-link" aria-label="Direct link to Getting an empty court for free" title="Direct link to Getting an empty court for free" translate="no">​</a></h2>
<p>Finding painted lines is easier once you clear the players off them. The
trick: the broadcast camera holds still for the whole rally (checked in
the appendix). Stack every frame of the clip, and for each dot on
screen, take the middle value it shows across every frame — its median.
A player is only ever briefly at any one dot, so the middle value votes
them out of existence. The lines never move, so they survive razor
sharp. It's the same free trick the <a class="" href="https://optic-yellow.com/primer#background-subtraction">Plain English
primer</a> uses to explain how the
pipeline later finds players — here it just clears the stage.</p>
<p><img decoding="async" loading="lazy" alt="The temporal median of the rally clip: an empty tennis court with crisp lines, both players erased" src="https://optic-yellow.com/assets/images/m1-clean-plate-5be255aa56d73b6e0377ac52bb4670af.webp" width="1280" height="720" class="img_ev3q"></p>
<p><em>A tennis court with nobody playing tennis on it — built by stacking
frames that all had somebody on it.</em></p>
<p>One line of code, and both players vanish, the ball vanishes, the lines
stay razor sharp. A couple of line judges, who barely move, leave faint
ghosts, and the on-screen graphics stick around too — neither matters
for what's next.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="finding-the-lines-and-one-impostor">Finding the lines, and one impostor<a href="https://optic-yellow.com/blog/m1-pixels-to-meters#finding-the-lines-and-one-impostor" class="hash-link" aria-label="Direct link to Finding the lines, and one impostor" title="Direct link to Finding the lines, and one impostor" translate="no">​</a></h2>
<p>Court lines are white paint on a blue court. So: isolate the big blue
rectangle first, and only look for white dots <em>inside</em> it. That one rule
keeps the crowd's white shirts, courtside signage, and the scoreboard's
text out of the search entirely; the blue court fills about a quarter of
the frame.</p>
<p>Run a standard line-finding routine on what's left, and 48 short
segments come back, covering every painted line on the court.</p>
<p><img decoding="async" loading="lazy" alt="The clean plate with 48 detected line segments drawn in yellow, covering every court line" src="https://optic-yellow.com/assets/images/m1-lines-overlay-b9e58a320f07348b7934c42272475945.webp" width="1280" height="720" class="img_ev3q"></p>
<p><em>Every line the court owns — plus one it doesn't.</em></p>
<p>Every real line, except the search also grabs an impostor: <strong>the net
cord reads as a line too.</strong> It's white, roughly horizontal, and
stretches most of the way across the frame — but it isn't painted on
the ground, and it isn't even straight; a net sags under its own
weight. Any scheme that just grabbed the horizontal lines would trip
right over it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="four-corners-is-enough">Four corners is enough<a href="https://optic-yellow.com/blog/m1-pixels-to-meters#four-corners-is-enough" class="hash-link" aria-label="Direct link to Four corners is enough" title="Direct link to Four corners is enough" translate="no">​</a></h2>
<p>The fix is almost embarrassingly simple. Group the found lines into
families — side-to-side, toward-the-camera — and keep only the
outermost member of each: the top and bottom side-to-side lines are the
baselines, the left and right toward-the-camera lines are the outer
sidelines. The net sits in the middle by definition, so it can never be
an outer line; it's excluded automatically. Cross those four outer
lines and you get the court's four corners. Hand those four points, plus
the court's real dimensions, to a standard piece of geometry software,
and out comes the homography.</p>
<p>Four corners is about as little as a homography can be built from. Is
it any good? The honest test: three pairs of lines never went into
building the conversion at all — the singles sidelines, both service
lines, and the center line. Run the full court diagram back through the
conversion and see where those untested lines land.</p>
<p><img decoding="async" loading="lazy" alt="The broadcast frame with the full court model reprojected on top: yellow fit lines and orange held-out lines all sitting exactly on the painted lines" src="https://optic-yellow.com/assets/images/m1-reprojection-1ab943adf321df9ba71a432345c2732b.webp" width="1280" height="720" class="img_ev3q"></p>
<p><em>Yellow: the lines the conversion was built from. Orange: lines it had
never seen before this moment — landing right on the paint anyway.</em></p>
<p>They land almost exactly on the paint, off by less than one screen dot
every time. From four corners, on the first try. I had a refinement step
planned in case the numbers came back rough, and deleted it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="where-does-the-ball-actually-go">Where does the ball actually go<a href="https://optic-yellow.com/blog/m1-pixels-to-meters#where-does-the-ball-actually-go" class="hash-link" aria-label="Direct link to Where does the ball actually go" title="Direct link to Where does the ball actually go" translate="no">​</a></h2>
<p>Now the payoff: take last post's ball positions — one screen dot per
frame — and run every one through the new conversion, then plot them on
a true-to-scale court diagram.</p>
<p><img decoding="async" loading="lazy" alt="Top-down court diagram with the ball track drawn as dots colored by time, crossing the net and clustering near the baselines" src="https://optic-yellow.com/assets/images/m1-track-court-7cada45bda460aed1f3c2200989ac462.webp" width="900" height="1500" class="img_ev3q"></p>
<p><em>The same rally, seen from directly above for the first time. Color
marks time.</em></p>
<p>Most of it reads like tennis: the ball crossing the net, dots clustering
low near the baselines where players make contact. Some of it doesn't —
a handful of points land more than 18 meters past the far baseline, well
outside any real court. Not a fit error; the untested lines above just
proved this conversion is accurate to a fraction of a screen dot. It's
the catch from earlier, on schedule: <strong>the conversion only knows the
ground</strong>, and a ball spends most of a rally in the air. What this chart
draws, for every airborne moment, is the ball's shadow, not the ball.
The shadow is truthful at exactly one instant — when the ball touches
down — and drifts further from reality the higher it flies.</p>
<p>Which hands the next post its whole job: find the moments the ball
touches the ground — the bounces — because those are the only moments
this chart isn't lying.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-scoreboard">The scoreboard<a href="https://optic-yellow.com/blog/m1-pixels-to-meters#the-scoreboard" class="hash-link" aria-label="Direct link to The scoreboard" title="Direct link to The scoreboard" translate="no">​</a></h2>
<table><thead><tr><th>Check</th><th>What happened</th><th>What "good" looks like</th></tr></thead><tbody><tr><td>Camera holding still all rally</td><td>Stacked "empty court" photo lined up with frame one almost perfectly</td><td>Should land right on top</td></tr><tr><td>Lines found automatically</td><td>48 segments, every real court line — plus the net cord, caught and thrown out</td><td>—</td></tr><tr><td>Accuracy on lines never used to build the fit</td><td>Off by well under one screen dot</td><td>Under ~1 dot = dead-on</td></tr><tr><td>Human clicks / cost</td><td>Zero clicks, $0.00</td><td>—</td></tr></tbody></table>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-takeaway">The takeaway<a href="https://optic-yellow.com/blog/m1-pixels-to-meters#the-takeaway" class="hash-link" aria-label="Direct link to The takeaway" title="Direct link to The takeaway" translate="no">​</a></h2>
<p>The ball — tennis's famous hard problem — needed a paid AI and a human's
mouse click to follow. The court — the part that sounds like real
computer vision — needed four corners and free arithmetic. By the end
of the day, the hard problem and the easy problem had traded chairs.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><ul>
<li class="">Camera stability: edge IoU 0.83, sharpness ratio 0.90 (median frame
vs. frame 0) — near 1.0, no pan, one homography covers all 480 frames.
Median of 96 sampled frames via <code>np.median(frames, axis=0)</code>.</li>
<li class="">Court-surface isolation: largest blue contour = 26.5% of frame;
white-pixel search restricted to inside it.</li>
<li class="">Line detection: Hough transform on masked white pixels → 48 segments,
clustered into 5 horizontal + 5 vertical families.</li>
<li class="">Corner selection: extremes of each family (top/bottom horizontal =
baselines, left/right vertical = doubles sidelines) → 4 corners →
OpenCV perspective transform against official dimensions (10.97 ×
23.77 m).</li>
<li class="">Validation: singles sidelines, both service lines, center line held
out entirely; reprojection mean error 0.0–0.8 px on those held-out
lines.</li>
<li class="">Net cord excluded automatically — never an extreme of either family.
The fitted model's net line is the net's dead-straight ground
projection; the real net cord sags visibly above it.</li>
<li class="">M0's per-frame ball centroid pushed through the fitted homography onto
the true-scale court model; some points land &gt;18 m past the far
baseline mid-flight — a ground-plane-only mapping meeting an airborne
ball, not fit error (confirmed by the sub-pixel held-out-line result
above).</li>
<li class="">Total cost: $0.00 — zero manual clicks, zero API calls; numpy and
OpenCV, run locally.</li>
<li class="">Carried to M2: read hit/bounce events from the discontinuities in this
same trajectory.</li>
</ul></div></div></details>
<p>This step cost nothing — no clicks, no paid API calls, just code on my
own laptop. Total for the milestone: $0.00.</p>
<p>Next question: the ball's path on screen is smooth curves broken up by
sudden kinks. Is every kink a hit or a bounce? Time to find out if that
idea survives contact with real data.</p>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
        <item>
            <title><![CDATA[M0: Can SAM 3 Hold Onto a Tennis Ball?]]></title>
            <link>https://optic-yellow.com/blog/m0-first-tracked-rally</link>
            <guid>https://optic-yellow.com/blog/m0-first-tracked-rally</guid>
            <pubDate>Thu, 09 Jul 2026 12:00:00 GMT</pubDate>
            <description><![CDATA[An AI that finds objects by description can't tell one tennis ball from six on court — until you point at the one in play.]]></description>
            <content:encoded><![CDATA[<p><em>That clip up top: an AI following the game ball through a full broadcast rally. The box is the model's answer, not something I added by hand.</em></p>
<p>Before software can chart a single stroke of tennis, it has to do the
simplest thing a ball kid does without thinking: watch the right ball, the
whole rally through.</p>
<p>Short answer: yes, it can — with a catch, and getting there took four tries
and two dead ends. Here's how.</p>
<p>The <a class="" href="https://optic-yellow.com/blog/auto-charting-kickoff">kickoff post</a> laid out the plan: teach
software to watch broadcast video and produce the same shot-by-shot chart
that Match Charting Project volunteers write by hand today — who served
where, how the rally went, who won the point. This post is step one:
before anything smarter can happen, the software has to prove it can keep
its eyes on one ball.</p>
<p>Picture the court between points: the ball actually in play, plus every
spare — tucked in ball kids' hands, a player's pocket, by the net post.
Shout "watch the ball!" and anyone would reasonably ask which one. That's
effectively the instruction the AI got on the first attempt: the words
"tennis ball." It's what's called a <em>promptable</em> model — instead of
training it on tennis ahead of time, you just tell it, in the moment,
what to look for. Told to find "tennis ball," it found all of them, game
ball included, and drew one box around the whole group. That's matching a
description, fresh, everywhere it fits — not following one ball.</p>
<p>The fix: point instead of describe. Draw one box, once, around the <em>one</em>
ball actually in play, in a single picture. That's a different kind of
instruction — not "find anything that looks like this" but "follow this
exact object" — and the model carries that one ball forward through the
video, remembering which ball is the right one from picture to picture.
That's called <strong>tracking</strong>: locking onto one specific object and
following it through the clip, rather than spotting fresh matches from
scratch each frame. (More on the difference, with more tennis examples:
<a class="" href="https://optic-yellow.com/primer#detection-vs-tracking">Court Vision in Plain English</a>.) A text
prompt matches every instance of a concept in the picture; a box drawn
around one specific ball tracks that ball, and only that ball.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-setup">The setup<a href="https://optic-yellow.com/blog/m0-first-tracked-rally#the-setup" class="hash-link" aria-label="Direct link to The setup" title="Direct link to The setup" translate="no">​</a></h2>
<p>I grabbed a 16-second broadcast rally — Zverev vs. Gasquet, in Montreal —
to test that idea on real footage. Finding a usable clip was its own
small project: the first highlight compilation kept cutting away from
the rally to close-ups and crowd shots after a few seconds. It took two
tries to find one continuous shot of a full rally from a single camera.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="attempt-1-describe-the-ball-and-the-players-together">Attempt 1: describe the ball and the players together<a href="https://optic-yellow.com/blog/m0-first-tracked-rally#attempt-1-describe-the-ball-and-the-players-together" class="hash-link" aria-label="Direct link to Attempt 1: describe the ball and the players together" title="Direct link to Attempt 1: describe the ball and the players together" translate="no">​</a></h2>
<p>I asked the AI to find "tennis ball, tennis player." The picture looked
right for the first several seconds — clean outlines around both players.
Then, partway through the clip, it abruptly flipped: instead of the
players, it was now outlining the entire court. The model had lost its
train of thought and locked onto something else entirely. (Why, and
exactly when, is in the appendix.)</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="attempt-2-describe-just-the-ball">Attempt 2: describe just the ball<a href="https://optic-yellow.com/blog/m0-first-tracked-rally#attempt-2-describe-just-the-ball" class="hash-link" aria-label="Direct link to Attempt 2: describe just the ball" title="Direct link to Attempt 2: describe just the ball" translate="no">​</a></h2>
<p>Dropping the players and asking only for "tennis ball" did return one box
for every single picture in the clip — 480 of them, which sounded
promising. Then I plotted where that box actually sat, frame by frame:</p>
<p><img decoding="async" loading="lazy" alt="The tracked box&amp;#39;s position over time: both coordinates sit in long flat stretches with sudden jumps between them, instead of a smooth path" src="https://optic-yellow.com/assets/images/m0-trajectory-textprompt-91a5d42727ce3f4300fb847dbee17400.webp" width="1600" height="915" class="img_ev3q"></p>
<p><em>The box the AI called "the ball," over time. Long flat stretches — a
ball in a live rally never sits still like that.</em></p>
<p>A ball in play is never still for long; this chart was, flat stretch
after flat stretch. Drawing the box back onto the video explained why: it
wasn't tracking one ball, it was covering all of them. Broadcast courts
commonly have something like six balls in view at once — the game ball
plus every spare a ball kid is holding — and the AI had drawn one
enormous box around the entire group, roughly the size of half the court.</p>
<p><img decoding="async" loading="lazy" alt="A tightly zoomed-in crop of the broadcast frame, showing the actual game ball as a tiny smudge a few pixels wide against the blue court" src="data:image/webp;base64,UklGRl4KAABXRUJQVlA4IFIKAAAQRACdASqQASwBPmEwlUgkIqSloJJJSLAMCWdu1Tass4w2f5XDOuKkh0y3erptnjTjadl1I04Jj+PNXNUtoHS04Jjaj9Lm3husZm4p+2CItnkyNUwp+2xsFXCQGqtB4ViiprAsh0qnPl0RNrcJGM77UFlZnlMQyYLqjvAsw5bHx/+hdsSGOvJjuHs/Gxjh8qBHjY62wQ28qjtCch10NV8ZSHS909B3fHB+Jl/KfkW2uSjKpBBpQBRThE0fAE06+gwd2QY5L3leEwYb8ci4bDyEcAlvuZ7Ts8ubklfOs+OPV5exDSoeZ2WRsRuCfXlfBct+m6d5uPkdIzGtD/n76JIWqd7Xcut14o2DG08UIiBPqAoZJFhvD9qsEFsbzOZfl0mnBDYsqHkijJh66RrVU8uU0QHoeoaXuWAfRSG1dH297yWF28NaxokPUgNvoTlpKikAVqAnQbL2SsPAKIzi8oDzIKk4r1ki39ICPgkkZSQ/4x9KnXl1iwYz7nXo/VJsE/3nTtlxDk3/8uxm+DngG4R1NB2MyynI4R+CAXFMbcXhlIzHEU7/9rfrQv81pitijoh5XkF/mXsGJuOQiDsdAc/rtc6duFz3DTtV7k16r+8BO4B532S8xXM4u/44m0w9zzMbVXwORfTvGvvcRcB96YUDEOxc+bLHbsJq6TBS1ndZRHVj3kWV3cgMKuHXRsndjrzPTvLqPqII8h3YJcw5h93zaxj3qQD5t3oAAP6Gvv/3B+oP4geD+qqevxKFjM6B94gXYezP9iF+TPrf97Hnfn7XA1NAEy1SkT/cpHXfk3pK549+TVLnAVLXn9TtecirZPEiv2BrZb8qYmpduhdG5RHB/MgXbXDwAB1dCLwArTZaB/VjbPGNGyD3xoabeevtfrtHnsi4Zliplqx+MwfLUsXJ+ALcBYSzcqqICQIfUrOS/qjw0XSk/Cbe4sC8yoz7r7+UcgFvfD7/gqhbGUWzcKHCrqzVIs9nSNjOwjcPgAXj9x1jp6U39/hJUJeLcRpblS2sxu6C3KD5zJeBvgzCKpZEfSNf5dip32AwuDuWAoCyucHREbiN4mlew2asxjhp3wnGmWvf4JJN6cDJjxh6oLGd0HB47ndayeGE8r2u4LxnAga1CbElsOu0WaER+l1oLFCKA9pyLhn4e45FVR9LfnIuoKyG4WwCOVZ4PtZOBP4CJOKLqZxDgVMTPrV6W2jLRienOzOQNkYqusqHVrlOs7Y2zldgEHQc0a3pnN5U+f7LjoVOlD7dbYjyPvVpQnN3xsvwdUyOR3ZpRhi/rLOW4oVkjtcEITak+kLgSVTU2VONgeHgTMZsW3zWy+UGnn1enbmNQ6A5juK+wtLjsk1c4ROS6u8RyNcxAVahjW3kcVJlVWuggSTuXEUWciyEjiOKS58zOZ4r3rFwVDA/Zb75ejB8W4laV8Y6lzuuPmLudIidkLpU5zkcAKn/im0kcMs782PL4rheqMxOBGNXs80NEcJSo5Wu29skNNI1VnSzk7z15BHdTpYDGLBH20BnDg07KCc9HJIgbo3d7/b8CmIO+Hc2nrfemHGrZ81e7/Zd9xA/+riR0feywEWbGqwYYOOFgycD5K20VhUQaNZLYvZ2AU7QdXSseg6vrgM7bMfJ8MezDe+cY1aPxfhnNsm36ki2JlrApaPVyEp99CKUmmsITMP0s1XLB/ySad1O9Wnq1/dx1IvOugANOfrTUV5Ej4DkJlQI+XrkUQrgEWvA7EaOJALmjs8JbLjL4hf02MOxX1nDXv403VRWwdwehaVZY/M4oZP55b4RVEqAkddNyRmsnDXidxrE7cxw9CslR2/HCTSUPz9E3XsipkuSXkopAkrb3kBTGIZ4fmStJ66UA/eR8z3ibTeJZ+on8OPInWIvVAo3SBchbCS7VWU03uPd6+hewCMar2lk+7KK/qd/60v+DxrOAG/4Y67ahtXR77dOx6HjSvKmu/qiCxlRsNQJMV4VIQjZGMyaaaRbCTSaNU6AAP5h+uY7nJxCG6qgcYqhMNwWmUKp9DKsv7pKwISMpF/Og+7GbxwRzTj4mqlJz650qEScatpvLswi6MkXR6wxe9b5xZ2WqS3l2+lVkGbzQDzkwekOnrOPwDz5fOWAjRUULW5Mlb/8KVuDyPi6PcURNa/wFbpbyhR82NrlgkQG+H56Q4wCwoqjc5A2mmI4qyNUAr0IIdVijgpi/ypGC6DPUb3jY/b/r+yZdepABd7pdej5vQhVMLr8+1/bSdY9w4Cudy2LRsRDzIKn5GykBSisfTJ+hePXiREX6i4Xeze5oX64s/6VR9FjuWeCHa4myBECP33HLHcnI4lj2vQ+ToeNsy7cji+LhfzuFpECEhxswWUrL98OeyYlFJbZbhDt7tqn8pueZGaNCHXSFjaynkRRsa0A1jqrXJP04CqzkOqLua9QpwgWe2W4CN9TD/3HwRxZVHR+FRkJ5H2Sh08bJd3sg93CX5+a2CmVLqYg2TFArLuHiAACZx+4XoZOMvSPx6Ohx5tKnANgyL+JuWkKnU/TXJh0NdVoeeU/6Wid6GIDcbOcMvobbAGao2l3X20jYB8si6cRLZsuQff/9TPqYuE4whFu6B5iBxDy9Ag7jTc1z1M0M069Mmn+OJjRpABk5rcPBWYMXycaA9EHAEBokxDZATISd4ekPZEKMGw0s0PwY094ptXbBmhNPnnJaSB993dTgYpfgdHy8NAUJY0T/VZ/7YEacS+vD27/X4eeAmNYsc28wy2TcOj5erFwIM8FUpjGDAKuxrNW7aTqLIDO/JG8IYe4tfQ3Zg8RexdLni0ZnurlQTofnb4vgRpKQEx4f+NOnpb1LJAgDmyyHt6sYVip8VUtvbAl05X/FJhJ1AWSvcHv+8MIkxBr+4ni0HB7auDXritlUF4qyXSadFOSGPi53EL1I5F8k6zrvQRuYiN35AwGz++o762vnanU2s31uJ2Vj/sGvGtge2+ikfIq8wF49gkpBOHnsXdBtMRVe2bB9k5edHWhfbDPMJin98dhogffwtGrQE3YMeuinWduDwQ28N71d4iNgXwC5ZZ9GLANG3oKYHdxqViya+SU1GJikEMAYPEOsYiZDWk+ydy92llKkLAqqsqQV/5+J5rjhyEcgpsKjWehEFV0ArQLkz9Ct4tNKKUaTY/1SL/uMA4OBE08iaYIwnbh/A1FrbSU/VkhgvUzIwwhig9ujWod+4paeLEdjQSF87OmA4WKRdb61bqt9S/47rl7J9e5hDmIYEmt+zjb4XWdzaBTC5AQqernU9wou3w/BxZF0WaEJ5qjkS9nMjk4H2jOCjVgI12q68Rl3R0SEvw/h2FZA7TPj8hWH+PIypVE9neV8F/hpG6rrlDcS/UNjDyEDR+zBXXj5i1bWty2F0zyTi0lLyzn6RcYQqe1PkolbesvVwAxPP2uwF+4VeiYusu2/2BDg4fgzZhO47ujWx+HwAID4Bc9kAAGOa1PNgBRrg3lF2vF2hAAlF2JWPgP0gjASEi0gAADFNaC1+TAyAAAAA==" width="400" height="300" class="img_ev3q"></p>
<p><em>The actual game ball — a smudge maybe eight pixels wide, moving at
200 kilometers an hour, contributing almost nothing to a box that size.</em></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="attempt-3-point-at-it-instead">Attempt 3: point at it instead<a href="https://optic-yellow.com/blog/m0-first-tracked-rally#attempt-3-point-at-it-instead" class="hash-link" aria-label="Direct link to Attempt 3: point at it instead" title="Direct link to Attempt 3: point at it instead" translate="no">​</a></h2>
<p>New plan: skip the words, point at the object. I found the game ball by
eye in one frame, drew a box around it, and let the model carry that ball
forward and backward through the rest of the clip on its own. Most of the
clip came back with sensible, ball-sized boxes — a real improvement. Then
the very first numbers I decoded came out negative, which a box's width
can never be. Turned out to be a units mix-up, not a tracking failure
(full story in the appendix). Read correctly, the numbers lined up
exactly on the ball I'd pointed at.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="attempt-4-the-payoff">Attempt 4: the payoff<a href="https://optic-yellow.com/blog/m0-first-tracked-rally#attempt-4-the-payoff" class="hash-link" aria-label="Direct link to Attempt 4: the payoff" title="Direct link to Attempt 4: the payoff" translate="no">​</a></h2>
<p>With the numbers read correctly, the tracked path finally looked like a
real rally:</p>
<p><img decoding="async" loading="lazy" alt="The game ball&amp;#39;s height over time from the box-prompted track: a smooth line tracing three clean up-and-down arcs, with a sharp direction change at the top of each one" src="https://optic-yellow.com/assets/images/m0-trajectory-boxprompt-5b6778ca716df4bfe651033fdbeff24f.webp" width="1600" height="915" class="img_ev3q"></p>
<p><em>The game ball's tracked position, after pointing at it in one frame and
letting the model follow it both ways through the clip.</em></p>
<p>Three clean trips of the ball from baseline to baseline, with a sharp
reversal in direction at each hit — exactly the shape a ball traces when
it's actually being struck back and forth. Those reversals are exactly
the signal a later step will use to work out precisely when each hit
happened.</p>
<p>Two surprises. I'd only pointed at the ball in one <em>middle</em> frame, yet the
model filled in its position both backward and forward on its own. And
track death has a clean tell: once the ball leaves play, the tracked box
doesn't vanish — it freezes in place and balloons to several times its
normal size. A dead track looks like a frozen, oversized box, as readable
a signal as a late line call. (Exactly where, and the fix, is in the
appendix.)</p>
<p><img decoding="async" loading="lazy" alt="A broadcast frame partway through the rally, with the ball&amp;#39;s tracked path drawn as a line across the court and a small box marking the game ball" src="https://optic-yellow.com/assets/images/m0-boxframe-8bd16a9b987939c01b599afedd957f40.webp" width="1280" height="720" class="img_ev3q"></p>
<p><em>One frame from the rally, tracked path drawn in — the box sits on the
game ball, not a ball kid's spare.</em></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-verdict">The verdict<a href="https://optic-yellow.com/blog/m0-first-tracked-rally#the-verdict" class="hash-link" aria-label="Direct link to The verdict" title="Direct link to The verdict" translate="no">​</a></h2>
<p>First milestone: done, at least for the ball — which was the part I
genuinely doubted going in.</p>
<table><thead><tr><th>What was tried</th><th>What happened</th></tr></thead><tbody><tr><td>Describe the ball in words</td><td>Locked onto every ball on court as one giant, motionless box</td></tr><tr><td>Point at the one ball with a box</td><td>Tracked cleanly for 470 of 480 frames — about 98% of the clip, essentially the whole rally</td></tr><tr><td>Total cost, all four tries combined</td><td>About $0.60 — dead ends included</td></tr></tbody></table>
<p>Two things are still open, not quietly skipped. Players aren't tracked as
cleanly as the ball yet — the same fix should work, but that's a guess,
not a result. And the box is still hand-drawn: a person clicks the ball
once per rally to start the pipeline. Workable, but not the automation
this project needs. Both, in depth, are in the appendix below.</p>
<p>Tell an AI to watch "the tennis ball" on a broadcast court and it will
dutifully watch every ball in the picture at once. Point at the one that's
actually in play, and it will follow just that one, the whole rally
through.</p>
<details class="details_lb9f alert alert--info details_b_Ee" data-collapsed="true"><summary>For the technical reader</summary><div><div class="collapsibleContent_i85q"><p><strong>Clip and tools.</strong> 480 frames, 720p, 30fps, via <code>yt-dlp</code>. Compute:
fal.ai's hosted SAM 3 endpoints —
<a href="https://fal.ai/models/fal-ai/sam-3/video" target="_blank" rel="noopener noreferrer" class=""><code>sam-3/video</code></a> (masked overlay
video) and <a href="https://fal.ai/models/fal-ai/sam-3/video-rle" target="_blank" rel="noopener noreferrer" class=""><code>sam-3/video-rle</code></a>
(RLE — run-length-encoded — masks plus per-frame boxes). About half a
cent per 16 frames, no GPUs to manage.</p><p><strong>Attempt 1 detail.</strong> Prompt <code>"tennis ball, tennis player"</code>, both
endpoints. Player segmentation looked clean for ~8 seconds — correctly
catching the line judges and ball kids too, since they're dressed like
players. Around frame 240 the mask flipped to segmenting the entire
court. Logs: <code>Completed chunk 2/2</code>. fal processes video in chunks; the
second chunk appears to re-run concept detection from scratch and land on
a different match — chunk boundaries are concept-drift boundaries. The
RLE endpoint, same prompt: one box per frame, a single giant region
merging everything matched, every confidence score null.</p><p><strong>Attempt 2 detail.</strong> Prompt <code>"tennis ball"</code> alone, detection threshold
0.3. Returned 480 boxes (one per frame), each the union of every tennis
ball visible in frame — game ball contributing almost nothing to a box
that size.</p><p><strong>Attempt 3 detail.</strong> Game ball located by eye in frame 240, pixel (622,
462); box prompt anchored there. 470/480 frames returned boxes. The
negative widths on first parse were a units bug, not a tracking bug:
box-prompted responses come back as <code>[cx, cy, w, h]</code> — normalized
center-plus-size — not the <code>[x1, y1, x2, y2]</code> corners the text-prompt
responses use. Documented nowhere, as far as I can tell; found by staring
at impossible numbers until the shape of the mistake became obvious.</p><p><strong>Attempt 4 detail.</strong> Frames 0–290 returned ball-sized boxes tracing
clean flight arcs, propagated in both directions from the single
frame-240 prompt. Track death: box collapses to a frozen, oversized shape
once the ball leaves play around frame 300. Dropping any box wider than
0.05 (normalized) cleaned that up entirely.</p><p><strong>Open items.</strong> Player tracking: original scope was "ball and both
players"; attempt 1's player masks were promising until the chunk-boundary
bug ate them, and it wasn't revisited once the ball was solved. Players
are bigger, slower, and hundreds of pixels tall, so box prompts should
handle them too, but that's untested. The human-drawn box: still one
manual click per rally to seed the track. Candidate fix, for later:
text-prompt for every ball in frame, then keep whichever one is moving.</p></div></div></details>
<p>All in: about sixty cents, four tries, two dead ends. Next question: now
that the pipeline can find the ball in a picture, can it work out where
that ball sits on the court itself? A different kind of problem — less
about winning an AI's cooperation, more plain geometry homework.</p>]]></content:encoded>
            <author>trevor.ryan.mccormick@gmail.com (Trevor McCormick)</author>
            <category>Devlog</category>
            <category>Computer Vision</category>
        </item>
    </channel>
</rss>