{
    "version": "https://jsonfeed.org/version/1",
    "title": "Optic Yellow Blog",
    "home_page_url": "https://optic-yellow.com/blog",
    "description": "Optic Yellow Blog",
    "items": [
        {
            "id": "https://optic-yellow.com/blog/stick-figures-beat-smudges",
            "content_html": "<p><em>That clip up top: the machine's new view of the players. Yellow stick\nfigures — wrists, elbows, hips, feet — drawn by a free model on real\nmatch frames, including the far player at ninety pixels tall. And one\nframe kept for honesty: the first version confidently drawing its\nskeleton on a ball kid.</em></p>\n<p>Today the software stops seeing players as smudges and starts seeing\nswings. The question: does that finally move the forehand-or-backhand\ncall that's been stuck for two weeks?</p>\n<p><strong>Previously.</strong> The machine finds players by comparing each frame\nagainst an empty-court photo (<a class=\"\" href=\"https://optic-yellow.com/primer#background-subtraction\">background\nsubtraction</a>) — which yields a moving\n<em>smudge</em> per player, no arms, no legs. The forehand/backhand letter has\nbeen read from which side of the smudge the ball arrives on, and that\nidea has formally hit its ceiling: <a class=\"\" href=\"https://optic-yellow.com/blog/the-draft-grades-itself\">careful\nexperiments</a> showed no amount of\nre-anchoring the smudge changes the calls. Meanwhile this same week the\n<a class=\"\" href=\"https://optic-yellow.com/blog/the-court-that-fits-itself\">court learned to fit itself</a> and the\nmachine <a class=\"\" href=\"https://optic-yellow.com/blog/the-machine-grows-ears\">grew ears</a>. The players are the\nlast part still made of fog.</p>\n<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>\n<p>The upgrade is a technology every fitness app now uses: <strong>pose\nestimation</strong> — a model that marks seventeen body landmarks (wrists,\nelbows, shoulders, hips, knees, ankles) on any person in an image,\nturning a smudge into a stick figure. Instead of guessing a stroke from\nwhere a blob bulges, you can read the actual tennis: where the racquet\nwrist is, which side of the body it crossed to, how the shoulders line\nup at contact. A free, openly-licensed model does this at better than\nreal-time speed on this laptop — no cloud, no cost.</p>\n<p>The open question was never the near player, who fills a fifth of the\nscreen. It's the far player, who stands about ninety pixels tall —\nthumbnail-sized. Research on this exact problem predicted: shoulders\nand hips will hold up, wrists will get shaky. The audit stills in the\nclip say the prediction was right on both counts — the far player's\nskeleton is real and readable, and the model's own confidence in the\nfar wrists hovers around fifty-fifty, versus a solid two-thirds for\nshoulders.</p>\n<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>\n<p>Two matches with human answer keys, the two where the letter call is\nbenchmarked: the Paris clay final and the Wimbledon final — grass being\nthe surface where the smudges smear worst. Every method graded on the\nsame shots against the same human charts:</p>\n<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>\n<p>On clay: a tie, two points better — exactly what the earlier\ncounterfactual experiments predicted, since on clean footage the smudge\nand the skeleton mostly agree about where a body is. On grass, where\nthe boxes fall apart: <strong>thirteen points better</strong>. The skeletons help\nmost precisely where the old method fails hardest — the same pattern as\nthe ears last post, and it's not a coincidence: every new witness earns\nits keep in the old witnesses' blind spots.</p>\n<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>\n<p>The first version had a bug that one audit image exposed instantly. It\nmatched each skeleton to the player by picking the detected person\nnearest the smudge's center — and on one clay point, a rogue smudge box\n(an old known disease) sat so far from the actual player that the\nnearest human was a <strong>ball kid at the back fence</strong>. The machine\ncarefully drew a skeleton on the ball kid and graded his forehand.</p>\n<p>The fix is pure tennis sense: a candidate skeleton only counts if its\n<em>feet are standing in the striker's half of the court</em> — the\n<a class=\"\" href=\"https://optic-yellow.com/primer#homography\">screen-to-court conversion</a> makes that a\none-line check. Ball kids kneel at the fences and net posts; umpires\nsit outside the lines; players play inside them. The fix costs\ncoverage — with strict standing-room rules and shaky far-player ankles,\nthe matcher now declines about half the shots rather than risk grading\nthe wrong human — and recovering that coverage by tracking skeletons\n<em>across</em> frames, instead of judging each frame alone, is the obvious\nnext version. Fourth time this week that a rendered picture caught what\nthe accuracy numbers politely hid.</p>\n<p><strong>The takeaway:</strong> given actual arms to read instead of a fog bank, the\nmachine's forehand-or-backhand call improved most exactly where it was\nworst — and the day's best evidence was one frame of a very\nwell-postured ball kid.</p>\n<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\n<code>outputs/diag/</code>; run with\n<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),\nONNX Runtime CPU, weights auto-downloaded; ~1 s/frame with detection on\nthis laptop. Posed only at charted contact frames (cheap gate test, not\na per-frame rollout).</p><p>Protocol: MCP truth letters on length-matched aligned clips (the eval\nconvention); all four players right-handed per match configs. Feature\nA \"wrist-cross\": sign of (right-wrist x − shoulder-midpoint x),\nmirrored for the far player (faces the camera). Feature B \"ball-side,\npose anchor\": ball x at contact vs shoulder midpoint — the re-anchoring\ncontrol. Results (same-shots blob baseline in parens): t3 — A 37/50\n(74%), B 42/50 (84%) vs blob 36/44 (82%); t4 — A 21/35 (60%), B 23/35\n(66%) vs blob 16/30 (53%). Wrist-cross underperforming ball-side is\nexpected at contact-frame-only sampling: two-handed backhands and\npreparation frames confuse a single-frame wrist test; windowed\nskeletons are roadmap #9's job (the keypoint-graph model, where\nfew-shot literature shows 2-3x gains).</p><p>Keypoint confidence (Lsho/Rsho/Lwri/Rwri means): near 0.70-0.74 /\n0.50-0.57 wrists; far 0.61-0.65 / 0.45-0.57 wrists — matching the\n\"shoulders hold, wrists shake\" prediction for ~90 px players at 720p.</p><p>v1→v1.1: nearest-blob matching graded a ball kid\n(<code>outputs/diag/pose_blooper_ballkid.png</code>, kept); v1.1 requires\ncandidate ankles to project (via the homography) into the striker's\ncourt half — far: court-y ∈ [−4, 11], near: [12.8, 28], x ∈ [−1.5,\n12.5] — then takes highest mean confidence, blob box demoted to\ntiebreak. Coverage cost: t3 graded shots 93 → 50 (no fallback when\nankles fail the test); temporal tracking recovers this in v2.</p><p>Hero renderer: <code>experiments/render_pose_hero.py</code>.</p></div></div></details>\n<p>Session cost: $0.00 — openly licensed weights, laptop inference.\nProject total: ~$16.</p>\n<p>Next question: in one day the machine learned to call lines by physics,\nhear impacts, fit its own court, and read swings — four new witnesses,\neach stronger where the others are weak. None of them are wired into\nthe shipped pipeline yet; they're experiments with receipts. The next\nbuild is the courtroom: the fusion decoder that hears every witness,\nweighs each one's track record, and writes the chart no single witness\ncould — with the grammar of tennis notation making illegal testimony\nimpossible to write down.</p>",
            "url": "https://optic-yellow.com/blog/stick-figures-beat-smudges",
            "title": "Stick Figures Beat Smudges",
            "summary": "The forehand-or-backhand call has been stuck for two weeks. The fix: draw a skeleton on every player and read the actual swing.",
            "date_modified": "2026-07-22T23:00:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        },
        {
            "id": "https://optic-yellow.com/blog/the-court-that-fits-itself",
            "content_html": "<p><em>That clip up top: eight tournament courts, two overlays each. Green is\nthe court fit a human built by clicking four corners. Orange is a free\npretrained network's fit — zero clicks. On seven courts they're a few\npixels apart. On the Roland Garros plate — the one that broke the old\nautomatic fitter — the orange lands first try. And on the night match,\nthe machine does something arguably better than fitting: it declines.</em></p>\n<p>Today the software tries to take over the last job a human still does\nfor every single match: teaching it where the court is.</p>\n<p><strong>Previously.</strong> Everything this machine calls — serves, bounces,\n<a class=\"\" href=\"https://optic-yellow.com/blog/nobody-sees-the-ball-land\">wide-or-deep</a>, who's standing where —\nruns through the <strong>homography</strong>, the screen-to-court-diagram conversion\n(<a class=\"\" href=\"https://optic-yellow.com/primer#homography\">primer</a>) built from the court's painted lines.\nSince <a class=\"\" href=\"https://optic-yellow.com/blog/m1-pixels-to-meters\">the very first fit</a>, getting that\nconversion has ended one of two ways: an automatic fitter finds the\nlines, or — on stubborn footage — a human clicks the four corners by\nhand. The automatic fitter has a famous scar: at Roland Garros, faint\nbrushed-over baselines and reddish stands fooled it so badly its fit\nmissed the court by 74 pixels, and a human had to step in. Clicking\ncorners takes a minute. But a minute per match, times the thousands of\nmatches this project wants to chart, is the difference between a tool\nand a hobby.</p>\n<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>\n<p>The fix wasn't invented here, and that's the point. Someone on the\ninternet already trained a small network to find tennis courts in\nbroadcast frames — not by hunting for four corners, but by recognizing\n<strong>fourteen landmarks at once</strong>: every corner, every T, every place two\npainted lines meet. It learned from 8,841 broadcast images across hard\ncourts, clay, and grass. It's free, it's small, and it runs on this\nlaptop in under a second per image.</p>\n<p>Fourteen landmarks instead of four is the difference between asking a\ntourist for the two street corners they remember and asking a letter\ncarrier who knows every house on the block. If a couple of landmarks\nare hidden or faint, twelve others still pin the court down — and the\nfitter can even check its own work, by fitting on some landmarks and\nmeasuring how well it predicted the ones it didn't use.</p>\n<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>\n<p>Each court's automatic fit, measured against the human's hand fit at\nsix landmarks (the four court corners and the two net posts):</p>\n<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>\n<p>Seven of eight courts, fit to within a few pixels of a careful human,\nby a network that had never seen any of them. And the eighth result\nmight be the most important one on the board: on the dim night match,\nthe network found only 2 of its 14 landmarks — so instead of guessing\nfrom starvation-level evidence, the fitter <strong>abstained</strong> and left the\njob to a human. That's the behavior this whole project is organized\naround: an automatic call when the evidence is strong, a raised hand\nwhen it isn't — the same contract as the\n<a class=\"\" href=\"https://optic-yellow.com/primer#confidence\">confidence flag</a> that grades every drafted point.</p>\n<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>\n<p>One court disagreed with the human by 16 pixels, and I went in expecting\nto document the network's failure. The overlay says otherwise: on that\nclay plate, the <em>orange</em> lines sit squarely on the painted court, and\nthe <em>green</em> — the human hand fit that all the benchmarks treat as\ntruth — floats visibly inside the paint on the far side. The\n\"error\" may be the teacher's. It's not settled yet — the neutral\nreferee will be the pipeline's existing line-scorer, which measures any\nfit directly against the paint itself — but it's a healthy reminder\nthis series keeps getting taught: hand-made truth is still hand-made.</p>\n<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>\n<p>First run, every single fit came back wrong by the <em>same</em> enormous\namount — about 750 pixels, all eight courts. A consistent error is a\nconvention error, not a model error: the borrowed code, it turns out,\nalready doubles its coordinates internally (it assumes 720p footage),\nand my wrapper doubled them again. One rendered overlay exposed it\ninstantly; the numbers alone never would have named the cause. Third\ntime this week: <strong>eyes beat derivatives</strong>.</p>\n<p><strong>The takeaway:</strong> the last per-match chore a human always did — teaching\nthe machine where the court is — just went automatic for seven courts\nout of eight, and on the eighth the machine did the next best thing to\nbeing right: it knew it didn't know.</p>\n<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\n<code>outputs/diag/</code>; run with\n<code>PYTHONPATH=.:experiments uv run --with scipy python experiments/court_autofit.py</code>).</p><p>Detector: yastrebksv/TennisCourtDetector — TrackNet-style heatmap net,\n640×360 input, 15 output channels (14 keypoints + center), pretrained\non 8,841 YouTube broadcast frames (hard/clay/grass), median error\n1.83 px refined / 2.83 px raw per its README. <strong>No license file</strong> — fine\nfor this research phase; the blueprint's risk register already flags\nretraining a self-labeled replacement before any commercial use (every\nhand-fit match auto-generates 14-keypoint labels through its known\nhomography). Weights: 42 MB, CPU inference ~1 s/plate.</p><p>Method: run on each match's <code>plate_fit.png</code> (the stabilized median\nplate the hand homography was fit on); solve via the author's\n12-configuration search (reimplemented numpy-only): each candidate\n4-point configuration fits a homography, is scored by mean prediction\nerror on the <em>unused</em> detected keypoints (cross-validation, not\nself-consistency), best wins. Convert reference-canvas px → meters\n(doubles corners (286,561)–(1379,2935) ↔ 10.97 × 23.77 m) and compose\ninto 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,\nt7 4.7/8.6, t2 7.3/13.9, t4 8.0/15.2, g1 8.0/12.2, t3 15.7/29.0;\nt1 abstained (2/14 kps; config search requires a complete 4-point\nconfiguration). Config cross-validation errors 2.5–7.0 px on accepted\nfits. 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\ninternally; wrapper scaled again → uniform doubling, self-consistent\nconfig-xval, ~750 px landmark deltas. Caught by the first overlay\nrender.</p><p>Next wiring (blueprint roadmap #2's remaining half): use this as the\n<em>proposer</em> inside <code>fitcourt</code>, judged by the existing step-5 line-mask\nscorer with a calibrated accept threshold (the TVCalib abstention\npattern), plus the 1 Hz drift watchdog. Night feeds: try per-clip\nplates, gamma/CLAHE boost, or fine-tuning with the hand-fit frames the\nproject already owns.</p><p>Hero renderer: <code>experiments/render_court_hero.py</code> (montage of the\nper-match overlays).</p></div></div></details>\n<p>Session cost: $0.00 — pretrained weights, laptop inference. Project\ntotal: ~$16.</p>\n<p>Next question: the court now fits itself and the machine hears every\nimpact, but the players are still <em>smudges</em> — blobs from background\nsubtraction, which is why forehand-versus-backhand has been stuck at a\nceiling for two weeks. The research says the upgrade is stick figures:\nwrists, elbows, hips, feet, drawn on every frame by a free model that\nruns at 87 frames a second on this laptop. Time to give the smudges\nskeletons.</p>",
            "url": "https://optic-yellow.com/blog/the-court-that-fits-itself",
            "title": "The Court That Fits Itself",
            "summary": "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.",
            "date_modified": "2026-07-22T21:00:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        },
        {
            "id": "https://optic-yellow.com/blog/the-machine-grows-ears",
            "content_html": "<p><em>That clip up top: a 15-shot rally from the Roland Garros final, with\nthe soundtrack drawn as a strip along the bottom. Green ticks mark the\nmoments the video pipeline detected a hit. Yellow spikes mark the\nmoments the microphone heard an impact. The red zones are the seconds\nwhere the ball tracker recorded nothing at all — and the yellow spikes\nkeep firing right through them.</em></p>\n<p>Today the software gets a second sense: when its eyes blink, can its\nears carry the point?</p>\n<p><strong>Previously.</strong> The ball tracker — the tiny specialist network that\nfinds the ball in every frame (<a class=\"\" href=\"https://optic-yellow.com/blog/the-zero-dollar-specialist\">the zero-dollar\nspecialist</a>) — has one chronic\nweakness: it blinks. On clay especially, its track is full of holes,\nand every downstream call (hits, bounces, rally length,\n<a class=\"\" href=\"https://optic-yellow.com/blog/nobody-sees-the-ball-land\">wide-or-deep</a>) inherits the\nblindness. Last post, physics recovered the landing spots the eyes\nnever saw. This post goes after the holes themselves, with a witness\nthat doesn't have eyes at all.</p>\n<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>\n<p>Think about how you experience tennis from the cheap seats, or the next\nroom. You can follow the rhythm of a rally without seeing it: <em>pock —\npock — pock</em>. The strike of strings on a ball is one of the most\ndistinctive sounds in sport — sharp, short, and unlike anything else on\na tennis court. Every broadcast microphone picks it up, no matter where\nthe ball is, how small it looks on screen, or whether it just left the\nframe. Crowd roar rises and falls slowly; commentary murmurs along; the\n<em>pock</em> is a spike.</p>\n<p>That's the whole idea: <strong>onset detection</strong> — scan the soundtrack for\nsudden bursts of energy in the frequencies where racquet impacts live,\nand mark each one's exact moment. No AI, no training: the software\nslices the sound into 11-millisecond snippets, watches how much <em>new</em>\nenergy each snippet brings, and flags the spikes that tower over their\nneighborhood. Notice the unit: 11 milliseconds. A video frame is 33.\nThe ears, when they're right, are three times more precise than the\neyes can ever be.</p>\n<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>\n<p>One discovery before any listening could happen: <strong>every video this\nproject has ever downloaded is silent.</strong> The staging pipeline grabbed\nvideo-only streams from day one — it never needed sound until today. So\nthe soundtracks had to be fetched fresh, audio-only, from the same\nuploads. For the Roland Garros match the source was known. For the US\nOpen match it had to be re-found — and verified the way this project\nverifies everything: by receipt. The candidate video's length matched\nthe match reel to a tenth of a second, and then the real proof: after\nlining up the two recordings, a single fixed time-shift (95\nmilliseconds on one match, 310 on the other) snapped hundreds of\naudio spikes onto video-detected hits at once. A wrong recording can't\nfake that — there is no single shift that aligns someone else's match.</p>\n<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>\n<p>Two matches, both already graded against human charts: the US Open\nfinal (hard court, where the eyes work well) and the Roland Garros\nfinal (clay, where they don't).</p>\n<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>\n<p>Read that last row again, because it's the headline. Across the clay\nmatch, the ball tracker spent 78 seconds of rally time seeing nothing —\nand the microphone logged 196 impacts inside exactly those seconds.\nThe witness that never blinks works <em>best</em> precisely where the eyes\nfail worst. That's what the yellow-spikes-in-red-zones moments in the\nclip up top are showing.</p>\n<p>And a small piece of physics showed up in the data, unasked. When the\ndetector is set strict — only the loudest spikes — the number of\nimpacts per shot settles at almost exactly <strong>two</strong>. Two impacts per\nshot is precisely what a court hears: the racquet's <em>pock</em>, then the\nbounce's <em>thud</em>. The microphone is telling us it hears both, cleanly\nenough to count.</p>\n<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>\n<p>Set looser, the ears hear too much. A tennis broadcast is full of\nthings that go <em>pock</em>: line calls, grunts, the umpire's microphone\nthump, a ball kid's bounce, applause spikes. At the settings that catch\nthe most real hits, the detector logs about three impacts per shot —\nthe extra one is chatter. Asked to count rally lengths by ear alone\n(spikes divided by two), it lands within one shot of the human chart\nonly 3 times in 10 on hard court, worse on clay.</p>\n<p>So the verdict, stated precisely: <strong>audio v1 is a witness, not a\ncounter.</strong> It confirms moments and fills blind time with candidate\nevents; it can't yet tell a forehand from a line call. Teaching it the\ndifference — <em>pock</em> versus <em>thud</em> versus everything else — is a small\nclassification job, and the training data is free: thousands of\naudio moments already labeled by the video hits they align with. That's\nthe same flywheel this project keeps returning to — one sense\nteaching another, no human labeling anything.</p>\n<p><strong>The takeaway:</strong> the machine's eyes blink, but the court always makes\nthe same two sounds per shot — and now the machine can hear them,\ntimed finer than its own eyes can see.</p>\n<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:\n<code>outputs/diag/audio_hits_report.txt</code>; run with\n<code>PYTHONPATH=.:experiments uv run python experiments/audio_hits.py</code>).\nPure numpy — no librosa/scipy dependency.</p><p>Method: mono 22.05 kHz wav (yt-dlp bestaudio of the same uploads;\nidentity by duration fingerprint — g1 5039.40 s vs reel 5039.45 s\n[known ID]; t6 2412.22 s vs 2412.13 s [condensed-match upload found by\nsearch]). STFT 1024/256 hann; log-magnitude spectral flux over\n1–8 kHz; normalized by sliding 2 s median/MAD; peaks above a z-score\nTHRESH with 0.10 s minimum separation. A/V offset per match by grid\nsearch (±0.5 s, 5 ms steps) maximizing video hits matched within\n40 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\nbefore grading g1. Sweep: z=5 → 44% hits matched @2fr, 3.27\nonsets/true-shot; z=8 → 38%, 2.53; z=12 → 32%, <strong>2.00</strong> (the\nracquet+bounce ratio); z=16 → 26%, 1.50; z=20 → 25%, 1.25. Frozen at\n5.0 (highest within 5% of peak match rate). Naive rally length\n(onsets/2, serve-to-last-hit window) within ±1 of MCP truth: 39/128\n(30%) t6, 2/21 (10%) g1.</p><p>Hole metric: ball-track gaps &gt;3 frames between serve and last hit + 1 s;\nonsets falling inside them after offset correction: 156 in 67 s (t6),\n196 in 78 s (g1).</p><p>Staging finds, on the record: (1) <code>extract.py</code> cuts clips with <code>-an</code>\nand ingest always fetched video-only — the whole corpus is silent by\nconstruction; audio staging is now a first-class ingest step. (2) g1's\nclip cutter (the <code>experiments/</code>-grade score-bug pipeline) never\npersisted its reel windows; recovered because every clip measures\nexactly 1.6 s longer than its alignment window (verified on four\nclips), and the unknown pre/post pad split folds into the calibrated\nA/V offset. Cutters must persist their windows — noted for the package\npromotion.</p><p>Precedent: audio hit detection on broadcast tennis goes back to\nAPSIPA 2012 (Australian Open footage, F-score 68–77% audio-only);\nSony AI's table-tennis impact work (arXiv 2409.11760) reports\nmillisecond precision in controlled capture. Audio-visual fusion gains\non broadcast sports: arXiv 2011.04258. The v2 plan (racquet/bounce/\nother mel-CNN on video-hit-aligned pseudo-labels) is blueprint roadmap\nitem 8's transcript-alignment flywheel applied to sound. Hero renderer:\n<code>experiments/render_ears_hero.py</code> (g1_point_05, 15 shots, 4.0 s of\nholes, 12 onsets inside them).</p></div></div></details>\n<p>Session cost: $0.00 — the audio tracks were free, and everything runs\non the laptop. Project total: ~$16.</p>\n<p>Next question: the ears confirm the eyes and cover their blinks, but\nboth witnesses still hand their evidence to a court fit that a human\nclicked into place. Four corners, every match, by hand — the last big\nmanual knob on the machine. The soccer world already builds courts\nthat fit themselves and, more importantly, know when they've failed.\nTime to steal that too.</p>",
            "url": "https://optic-yellow.com/blog/the-machine-grows-ears",
            "title": "The Machine Grows Ears",
            "summary": "The ball tracker blinks at the worst moments. Broadcast microphones don't. The software learns to listen for the pock of strings on ball.",
            "date_modified": "2026-07-22T18:00:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        },
        {
            "id": "https://optic-yellow.com/blog/nobody-sees-the-ball-land",
            "content_html": "<p><em>That clip up top: two real points from the US Open final. In each one,\nthe yellow dots are the machine watching the ball fly — until the dots\nstop, mid-air, because the tracker loses it. Then the freeze-frame: a\nwhite line extends the flight the machine did see, racing toward two\nfinish lines — the sideline and the baseline — and whichever it crosses\nfirst names the error. Both calls match what the human charter wrote.</em></p>\n<p>Today the software learns to answer a question it has been failing,\ncompletely, since grading began: when a ball goes out, did it go out\n<strong>wide</strong> or <strong>deep</strong>?</p>\n<p><strong>Previously.</strong> This machine watches broadcast tennis and drafts a\nshot-by-shot chart of every point, which then gets graded, correction\nby correction, against charts written by human volunteers (<a class=\"\" href=\"https://optic-yellow.com/blog/within-one-token-of-a-human\">how the\ngrading works</a>). Most parts of the\ndraft have been inching upward for weeks. One part has not moved at\nall: when a rally ends with a ball landing out, the machine scored <strong>zero\npercent</strong> at saying which way it went out. Not \"poor.\" Zero — across 159\ntries, on eight matches, twice confirmed by different methods. The\nreason is simple and physical: the ball tracker loses sight of the ball\na split second before an out-ball lands. Fuzzy yellow blur, busy\nbackground, and the one moment that decides the call is exactly the\nmoment the machine goes blind.</p>\n<p>The obvious fix — make the tracker hold on longer — is the one I'd been\nplanning. Then a research deep-dive turned up something better, hiding\nin plain sight.</p>\n<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>\n<p>Think about what Hawk-Eye actually shows you. That smooth animated arc,\nthe ball flattening onto the paint, the \"OUT\" verdict by two\nmillimeters — none of that is a photograph. No camera on Centre Court\ncaptures the ball touching the ground; at that instant the ball is a\nsmear of pixels, half-hidden, moving at highway speed. What the system\nphotographs is the <strong>flight</strong>. The landing is <em>calculated</em> — physics,\nfitted to the part of the flight the cameras saw clearly, finishes the\nsentence.</p>\n<p>A veteran line judge does the same thing. She doesn't wait to study the\nskid mark. She reads the arc coming off the racket and knows the ball\nis sailing long <em>before</em> it lands. The information was in the flight\nthe whole time.</p>\n<p>That's the whole idea I stole this week: <strong>the boundary race</strong> — stop\ntrying to see the landing, and instead extend the flight the machine\n<em>did</em> see until it crosses a line. If the extended path crosses the\n<strong>sideline first</strong>, the ball was going wide. If it crosses the\n<strong>baseline first</strong>, it was going deep. Whichever finish line the flight\nreaches first names the error. (One more entry in the <a class=\"\" href=\"https://optic-yellow.com/primer#boundary-race\">plain-English\nprimer</a>, where the recurring ideas of this\nseries live.)</p>\n<p>No new AI. No new camera. Straight-line physics, fitted to the last\nstretch of dots the tracker recorded before it blinked — and two papers\nat this year's top computer-vision conference validate the family of\nidea, one of them placing landings within about 0.6 meters from a\nsingle broadcast camera.</p>\n<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>\n<p>I ran the race on every point in the benchmark — the eight matches\nwhere human charters already wrote down the truth — and looked only at\nthe points that genuinely ended with a ball landing out: 169 of them,\nof which 159 were plainly wide or deep (the rest were charted as both\nat once).</p>\n<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>\n<p>From nothing to half, with arithmetic. And when the race actually picks\na side — wide or deep, rather than hedging \"both\" or declining — it\nseparates the two at about five in six, on flights the tracker gave up\non.</p>\n<p>The misses are honest and mostly one story: the TV camera looks down\nthe court, so a flying ball always <em>appears</em> deeper than it really is —\nthe same distortion that makes every approach shot look like it's\nsailing long from your couch. That bias makes the baseline finish line\nfire early, so some wide balls get called deep or \"both.\" Fixing that\nlean is a real project for later, not a knob to fudge today.</p>\n<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>\n<p>Rendering that clip up top wasn't decoration — it caught two real bugs\nthe scoreboard had quietly absorbed.</p>\n<p>First: in one point, the machine's \"last sighting\" marker drew itself\nat the top of the screen while the ball's trail exited bottom-right.\nImpossible. The track data showed why: the ball supposedly moved 37\nmeters in three frames. Balls don't teleport — the tracker had lost the\nreal ball and latched onto something else, and those junk points were\npoisoning the physics fit. The fix is a rule of nature, not a tuned\nsetting: end the flight at the first physically impossible jump.</p>\n<p>Second: the race originally asked an earlier stage of the pipeline\nwhich end of the court the final shot was heading toward — and that\nstage was sometimes wrong. One point claimed the far player hit the\nlast shot, while the ball demonstrably died two meters beyond the <em>far</em>\nbaseline. Now the race reads the direction from the flight itself and\ntrusts nobody.</p>\n<p>Both bugs were invisible in the accuracy tables and obvious in one\nrendered frame. This series keeps re-learning the same lesson, so it's\nearned a bolding: <strong>eyes beat derivatives</strong> — when a number and a\npicture disagree, believe the picture.</p>\n<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>\n<p>One more result, because this devlog keeps its failures on the record:\nI also tried the lazy version of \"integration\" — feeding the race's\noutputs as extra hints into the existing ending classifier, the one\nthat learned from 11,646 human-charted matches. It did nothing. Zero\npercent stayed zero.</p>\n<p>The reason is instructive. Winners — balls that land <em>in</em> and end the\npoint — are nearly half of all endings, and thanks to that same\nlooks-deeper-than-it-is distortion, the race happily extends many\nin-balls out too. A single flat classifier drowns the new signal in the\nold base rates. The race's real gift is conditional: <em>given</em> that a\nball went out, it names the flavor. Deciding whether the ball went out\nat all needs different witnesses — a net-death, an observed landing,\nand soon (next on the roadmap) the sound of the ball itself. The\narchitecture lesson: don't ask one judge to answer two different\nquestions.</p>\n<p><strong>The takeaway:</strong> the machine stopped trying to see the ball land, read\nthe flight instead — the way Hawk-Eye and every good line judge always\nhave — and half of a stone-blind category came back at the cost of\nzero dollars and some algebra.</p>\n<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\n(report: <code>outputs/diag/landing_spot_report.txt</code>; run with\n<code>PYTHONPATH=.:experiments uv run python experiments/landing_spot.py</code>).</p><p>Method (v1, deterministic, no constants fitted to truth): final flight\nsegment = contiguous ball-track run after the last charted contact,\ncapped at 2.0 s, cut at the first &gt;6-frame hole and at the first\nteleport (&gt;3.0 m/frame court displacement — ~90 m/s, physics bound; the\nsame no-teleport gate <code>boxes.py</code> applies to players). Straight-line fit\ncx(t), cy(t) over the last ≤8 samples in court coordinates; target half\ninferred from the flight (already-out position wins, else the y-slope\nsign, else which half the ball occupies). Race: time-to-sideline\n(singles line ± the 0.25 m <code>OUT_MARGIN</code> from <code>endings.py</code>) vs\ntime-to-baseline, extrapolation capped at 0.7 s past track death;\ncrossings within 2 frames of each other → <code>x</code> (wide-and-deep); neither\nwithin cap → abstain <code>?</code>.</p><p>Population: <code>evaluate.evaluate</code> records, truth = <code>mcp_ending_type</code>\n(w/d/x), same denominators as <code>experiments/learn_components.py</code> plus\nmatch #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%).\nCommitted w-vs-d discrimination: 75/90 (83%). Per-match strict correct:\nt7 27/58, t6 28/44, t5 10/29, t4 9/14, t3 4/10, g1 3/11, t1 1/2, t2\n1/1 — clay (t3, g1) collapses because thin tracks leave no segment to\nfit, which the planned clay fine-tune of the ball detector multiplies.</p><p>Version history within the session, all on the record: striker-derived\ntarget + no teleport gate → wide 30/69, deep 35/90 (overall 41%);\n+teleport gate → 31/69, 33/90 (41%); +flight-inferred target → 20/69,\n55/90, overall 83/169 (49%), with the wide→x leak (27) being the\ndepth-inflation bias concentrating inside the 2-frame \"both\" window.</p><p>The refuted integration: appending racer features (call one-hot,\nt_side, t_base, n_seg) to the 4-class leave-one-match-out random\nforest (<code>learn_components.lomo</code>, seed 0) moved wide/deep recall 0%→1%\nand cost overall accuracy (59.0%→57.4%). False out-calls on true\nwinners/net: 204/317 (64%) — the racer is a conditional classifier\nP(wide vs deep | out), not an out-detector; it will enter the pipeline\nas a per-slot likelihood in the planned grammar-constrained fusion\ndecoder, gated by independent out-ness evidence.</p><p>Literature anchors: TT3D (arXiv 2504.10035, CVSports @ CVPR 2025) and\n\"Where Is The Ball\" (arXiv 2506.05763, same workshop; 0.63 m mean\nlanding error on broadcast tennis, 81 clips/10 matches). The hero video\nrenderer is <code>experiments/render_landing_hero.py</code> (points t6_point_110,\ntruth wide; t6_point_117, truth deep).</p></div></div></details>\n<p>Session cost: $0.00. Project total: ~$16.</p>\n<p>Next question: the race can name an out-ball's flavor, but something\nstill has to say \"that ball went out\" in the first place — and the\ntracker's eyes have proven they blink at exactly the wrong moment. The\nnext witness doesn't have eyes at all. Every broadcast microphone\ncourtside hears the same thing you do: the <em>pock</em> of strings on ball.\nTime to give the machine ears.</p>",
            "url": "https://optic-yellow.com/blog/nobody-sees-the-ball-land",
            "title": "Nobody Sees the Ball Land",
            "summary": "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.",
            "date_modified": "2026-07-22T10:00:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        },
        {
            "id": "https://optic-yellow.com/blog/the-33rd-charter",
            "content_html": "<p><em>That clip up top: the actual file, playing. All 134 points of the\nSabalenka–Pegula US Open final draft scroll past, and 39 light up\ngreen — \"trust this enough to correct instead of typing from\nscratch,\" a claim that held up 94% of the time on matches the model\nhad never seen. Two green rows then play against their video clips,\ndraft string and jump-to timestamp on screen. It ends with what the\nfile admits it can't do yet, and a question for anyone who charts\ntennis for real.</em></p>\n<p>For sixteen posts, this project has graded its own homework — checking\ndrafts against charts it already had the answers to. Today, for the\nfirst time, a draft goes to a real judge: someone who charts tennis\nfor a living, whose only job is to say whether it helped.</p>\n<p>Previously: <a class=\"\" href=\"https://optic-yellow.com/blog/auto-charting-kickoff\">cv-01</a> set the destination —\nthe Match Charting Project (charters call it the MCP), run by Jeff\nSackmann of Tennis Abstract, is volunteers watching tennis and writing\ndown every shot in a compact code; nearly 18,000 matches charted so\nfar. <a class=\"\" href=\"https://optic-yellow.com/blog/within-one-token-of-a-human\">cv-14</a> gave the project a way\nto grade a machine-written line against a human one: how many\ncorrections an editor would need, the <strong>token edit distance</strong>\n(<a class=\"\" href=\"https://optic-yellow.com/primer#token-edit-distance\">more here</a>).\n<a class=\"\" href=\"https://optic-yellow.com/blog/the-draft-grades-itself\">cv-15</a> turned that grade into a\nstoplight — HIGH (\"start from this draft\") or LOW (\"re-chart it\") —\ntuned so the flag never grades its own homework\n(<a class=\"\" href=\"https://optic-yellow.com/primer#confidence\">more on confidence</a>).\n<a class=\"\" href=\"https://optic-yellow.com/blog/the-benchmark-grows-up\">cv-16</a> grew the test to seven matches\nacross five broadcast feeds, 491 human-charted points, fixed a blind\nspot on the Wimbledon match, and landed the number this post leans on:\nHIGH is right about 94% of the time.</p>\n<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\ncharters</a>,\ncovering about a quarter of tour matches — and Sackmann published a\nplea for more. That's the door this post walks through.</p>\n<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>\n<p>Being graded correctly isn't the same as being useful. Groove a stroke\nagainst a ball machine for months and you prove your technique\nreproduces — never that it survives a live point, because a machine\ncan't return your shot and tell you how it felt. Every number this\nproject has published so far graded the pipeline's aim, not its use:\ndoes the drafted string match a human's, was a flag right when checked\nlater. Nobody who charts a match for a living has ever opened one of\nthese files and tried to work from it.</p>\n<p>That's the gap this post closes. The step where a tool's output leaves\nthe lab, lands on a real person's desk, and the only score that counts\nis what they report back, is the <strong>human loop</strong>: grading against a\nfixed answer stops, and getting judged by whether you saved someone\nreal time starts. Nothing in the pipeline changed this session — who's\non the other side of the net did.</p>\n<p>In tennis terms: the ask is what a doubles player makes of a\nprospective partner before ever stepping on court — play one set with\nme, then tell me if I made you faster or slower.</p>\n<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>\n<p>Seven files went public today, one per benchmark match, in the exact\ncolumn layout the MCP already uses for its own point-by-point files.\nThe familiar columns are all there — match, point number, set score,\ngame score, point score, server — a real charter could read those\nblind. Then come the columns Court Vision added:</p>\n<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>\n<p>A real row, from the US Open final, flagged HIGH at 0.902:\n<code>s5b2f1f2f2x@</code>. Read left to right: <code>s5</code> — serve into the body. <code>b2</code> —\nbackhand return, up the middle. <code>f1</code>, <code>f2</code>, <code>f2</code> — three forehands,\none into an outer third, then twice down the middle. <code>x@</code> — unforced\nerror, wide and deep. Six tokens, one point.</p>\n<p>Every match's file, with how many points it drafted and how many got\nthe green flag:</p>\n<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>\n<p>99 of 508 — about one point in five — got the green flag; cv-16 found\nit right about 94% of the time on matches the grading model never\ntrained on. Start with the US Open final: best hit rate in the batch,\nand the model called who served right 95% of the time there too — a\ncamera that holds still pays off.</p>\n<p>A drill doesn't prove anything until it's played against a real\nopponent. That's the whole reason this post exists.</p>\n<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>\n<p>A draft that hides its weak spots wastes an hour, so the weak spots\ncome first.</p>\n<p><strong>The drafts are drafts.</strong> Across all 491 human-charted points behind\nthe whole benchmark, only 5.7% — roughly 1 in 18 — match the human's\nline closely enough to need just one fix. The real value sits in the\ngreen rows: HIGH means within five corrections, holding up 94% of the\ntime on matches the grading model never trained on (96% on matches it\ndid study — the friendlier, less trustworthy number, included\nanyway). LOW, 409 of 508 rows, means exactly what it sounds like:\nre-chart from the video, draft as hint, <code>serve_s</code> as the jump-to.</p>\n<p><strong>Every missed serve is invisible.</strong> The pipeline can't yet see a\nfault, so every point is written as if won on the first serve, and the\nsecond-serve column is empty on all 508 rows. On a real scoresheet,\nroughly a third of points start with a miss. Fixing that isn't a\nfootnote of the charter's pass — it's most of it.</p>\n<p><strong>The shorthand isn't official yet.</strong> The <code>s</code> before a serve number is\nthe pipeline's own invention, not real MCP notation, and a <code>?</code> shows\nup when the machine can't tell which way a shot went. The draft also\nskips most of what a full MCP chart captures — no depth, no court\nposition, no volley vs. lob vs. drop shot; endings and rough\ndirections only. Whether to write strict, official strings with\nblanks instead, or keep the uncertainty visible, is undecided —\nquestion three, below.</p>\n<p><strong>Seventeen rows have no point number.</strong> The machine caught the point\non video but couldn't match it to a spot on the scoreboard, so that\ncolumn is blank. The point happened; a person still has to say where\nit belongs.</p>\n<p><strong>One answer key has problems of its own.</strong> cv-16's autopsy caught the\nWimbledon final's human chart running at half speed on some rallies —\nstrokes written down that the video contradicts. That file still\nships, 18 of its 49 rows green. Open that one first if you're hunting\nfor the model being too sure of itself.</p>\n<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>\n<p>If you chart for the MCP — or ever have — three questions matter here,\nin the order they'd change what gets built next.</p>\n<ol>\n<li class=\"\">\n<p><strong>The stopwatch.</strong> Chart ten points cold, straight off a clip.\nThen correct ten different green-flagged drafts against their\nclips instead. Which pass is faster, and by how much? The whole\nproject is a bet on that ratio, never yet measured by anyone who\nactually holds the pencil.</p>\n</li>\n<li class=\"\">\n<p><strong>The triage.</strong> Is a draft right 94% of the time, within five\ncorrections, the right bar for \"start from this\"? And with only\nabout one point in five earning that flag, does a pre-filled file\neven help mid-session — or does switching between \"check this\" and\n\"chart from nothing\" cost more than it saves?</p>\n</li>\n<li class=\"\">\n<p><strong>The schema.</strong> What would this file need before you'd reach for it\nmid-session — official MCP strings with blanks, a real fault\ncolumn, a charting-sheet layout instead of a spreadsheet, something\nelse entirely?</p>\n</li>\n</ol>\n<p>The channel is <a href=\"https://github.com/TrevorMcCormick/court-vision/issues\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">an issue on\ncourt-vision</a>\n— a one-line \"this wastes my time because X\" is signal, not rudeness.\nIt would be the single most valuable sentence anyone has contributed\nto this project.</p>\n<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>\n<p>Worth knowing before opening these files: the score and server columns\nexist because these drafts were lined up against the MCP's own\npublished charts — the same charts the 94% number is graded against.\nSo these files carry the MCP's license,\n<a href=\"https://github.com/JeffSackmann/tennis_MatchChartingProject\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">CC BY-NC-SA 4.0</a>:\ncredit the source, don't sell it, share what's built from it the same\nway. The draft strings and confidence columns are this project's own\naddition, released under that license to keep the files one whole\nthing.</p>\n<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>\n<li class=\"\">File key (<code>t1</code>–<code>t7</code>, internal match IDs, same order as the table\nabove): t1 Nadal–Shapovalov, t2 Federer–Haase, t3 Djokovic–Ruud\n(clay), t4 Krejcikova–Paolini (grass), t5 Sinner–Zverev (night), t6\nSabalenka–Pegula, t7 Djokovic–Sinner (indoor). Points/HIGH per match:\ntable above.</li>\n<li class=\"\"><code>match_id, Pt, Set1/Set2, Gm1/Gm2, Pts, Svr, 1st/2nd</code> — MCP's own\npoints-file columns, preserved as-is.</li>\n<li class=\"\">HIGH tier LOMO figure: 90/96 at 19.6% coverage.</li>\n<li class=\"\"><a href=\"https://github.com/TrevorMcCormick/court-vision\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">court-vision</a> is\npublic; run cost ~$16 across seventeen posts, marginal match $0.</li>\n</ul></div></div></details>\n<p>Session cost: $0.00 — nothing ran but a video renderer. Project total:\n~$16. The next number that matters isn't the pipeline's to produce:\nit's one charter's minutes-per-point, with and without the draft.</p>",
            "url": "https://optic-yellow.com/blog/the-33rd-charter",
            "title": "The 33rd Charter",
            "summary": "Court Vision introduces itself to the real volunteers who chart tennis matches by hand, and asks whether its drafts actually save them time.",
            "date_modified": "2026-07-11T14:00:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        },
        {
            "id": "https://optic-yellow.com/blog/the-benchmark-grows-up",
            "content_html": "<p><em>That clip up top: watch the test set grow, tile by tile, from four\nmatches to seven — the point counter climbing from 135 to 491\nhuman-charted points. Then its self-reported trust score recalculates\nlive on screen — down, then back up — as the reason for the dip gets\nfound and fixed. It closes on a real point\nfrom the Sabalenka–Pegula U.S. Open final: a serve into the body, a\nbackhand up the middle, a forehand rally ending wide and deep —\nflagged correctly trustworthy at a 0.902 score by a version of the\ntool that had never watched that match before.</em></p>\n<p>Would you trust a player's world ranking more if it came from one\ndazzling week at one tournament, or from a whole season played across\ndifferent surfaces and different opponents?</p>\n<p>Two posts back, <a class=\"\" href=\"https://optic-yellow.com/blog/within-one-token-of-a-human\">cv-14</a> gave the\nproject its scorecard: count how many fixes — changes, insertions,\ndeletions — a human editor would need to turn the machine's chart\ninto the real one — an <a class=\"\" href=\"https://optic-yellow.com/primer#token-edit-distance\">acceptance</a> at\none 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\nof that scorecard: every drafted point gets stamped HIGH or LOW, and\nHIGH means \"start correcting from this draft.\" But that flag had only\never been tested against 135 points from four matches — barely 1 in 9\nof which passed the one-fix bar to begin with — and cv-15 signed off\nwith HIGH at 93% reliable, plus a warning: 135 points wasn't enough\nto trust yet, and three more matches were already on the way.</p>\n<p>Here's why that matters. A hot week can flatter a ranking — a small\nfield, a favorable draw, a home crowd — and it isn't wrong exactly,\nit's just thin. A full season is the harder test: more matches,\ntougher and more varied opponents, and the ranking is allowed to go\ndown afterward even though the player didn't get worse. It's finally\nbeing asked to hold up over more evidence, and a ranking that\nsurvives a full season is worth more than a flashier one built on a\nsingle great week, precisely because it was harder to earn.</p>\n<p>That's <strong>recalibration</strong>: redoing the trust-score math against a\nbigger, tougher set of examples, and trusting the number wherever it\nlands — even down. (<a class=\"\" href=\"https://optic-yellow.com/primer#confidence\">More on how these trust scores get\nbuilt.</a>) The confidence flag lives or dies on two\nnumbers: how often it's even willing to say HIGH at all — called\n<em>coverage</em> — and how often HIGH is actually right when it does —\ncalled <em>precision</em>. It's the same trade a line judge faces: call\neverything close and you're helpful but wrong a lot; call only the\nobvious ones and you're always right but useless. The cv-15 flag had\nonly faced one hot tournament's worth of evidence. This session gave\nit a full season.</p>\n<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>\n<p>The pool of candidates was every match the Match Charting Project's\nvolunteers have hand-charted — this project's answer key, <a class=\"\" href=\"https://optic-yellow.com/primer#ground-truth\">explained\nhere</a> — cross-checked against what's findable\non YouTube. The winning format was the \"condensed match\" upload: a\n20-to-40-minute re-cut that keeps nearly every point and skips only\nthe between-point filler, which lines up with the human chart far\nbetter than a highlights reel. Three finals made the cut: the\nAustralian Open final at night, the U.S. Open final, and the ATP\nFinals indoors in Turin — 575 more charted points to draw from.</p>\n<p>All three ran through the pipeline's existing machinery untouched —\nno new code, just settings. Each broadcast had one quirk: a court's\nblue paint too bright for the usual color threshold, a green sideline\napron too close to the court's own color, a hazy light-blue apron\nneeding a brighter ceiling — all one-line settings fixes. One old\nlesson got relearned, too: the recipe that converts a screen position\ninto a court position — the <a class=\"\" href=\"https://optic-yellow.com/primer#homography\">homography</a> — has to\nbe built from footage where the camera is actually holding still. The\nfirst attempt smeared because the camera was still panning; a static\nfive-second window during the serve toss fixed it.</p>\n<p>New broadcasts also surfaced a new headache and caught an old bug.\nThe headache: all three hide the <a class=\"\" href=\"https://optic-yellow.com/primer#score-bug\">scoreboard\ngraphic</a> at 0-0, briefly confusing the tool that\nuses it to tell points apart — solved by cross-checking the human\ncharter's own serve notes. The bug: the method for tracking which end\nof the court a player serves from had been quietly miscounting\ntiebreak games as a full 13th game instead of the shorter,\ndifferently-scored game it actually is — a one-line fix that flipped\na whole match's worth of serve-end guesses from mostly wrong to\nmostly right. And on the record: an overnight background job died\nsilently, and two matches' worth of finished work sat untouched until\nsomeone checked by hand.</p>\n<p>None of that dented the results. Scored against the pipeline's\nunchanged rules, the three new matches set new highs: the U.S. Open\nmatch got the serving end right 121 times out of 128 (95%), and\nTurin got rally length right, give or take one shot, 131 times out of\n157 (83%). On the strict pass-fail test, the bigger set held\nessentially flat: 28 of\n491 points needed no correction at all (5.7%), close to the earlier 7\nof 135 (5.2%). The pipeline didn't get worse by testing it harder.</p>\n<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>\n<p>On the small set — 135 points, four matches — the confidence flag's\nHIGH rating was right 93% of the time, willing to call HIGH on 32.6%\nof points. Recalculated on the full season — 491 points, seven\nmatches, five broadcasts, same math, no changes — the honest numbers\ncame in lower: 88% right, called on 21.2% of points. That's not the\ntool getting worse. That's small-sample flattery washing out.</p>\n<p>One match makes the point almost too neatly. In the small test, the\nWimbledon match looked flawless: 11 for 11, 100% right, the best\nresult in the table. In the big test, with more than double the\nWimbledon points to check, that same match fell to 69% right while\nclaiming to flag more than half the match. The small test wasn't\nrigged — it just didn't have enough Wimbledon points yet to catch\nwhat was wrong. More data didn't break Wimbledon's score. It let the\nproject finally see it, and go looking for why.</p>\n<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>\n<p>The why turned out to be a single broken check.</p>\n<p>cv-15's best idea was a serve check: a real toss lands the ball's\nlaunch point more than 20 meters beyond the baseline, so a \"serve\"\nthat starts from inside the court is really a broadcast cutting into\nthe middle of an already-running point — and the draft gets marked\ndown for it. On its own, it had been the confidence project's single\nbiggest improvement.</p>\n<p>But it only works when the system identified the serve by watching\nthe ball's own flight. When the ball-tracking data is too spotty to\ntrust, it falls back to guessing the serve from the players' stance\nand position instead — and on the Wimbledon broadcast, every single\nserve was identified that second way. So the serve check had zero\nreal serves of its kind to examine, for an entire tournament's worth\nof points. It wasn't passing every point because it checked them and\nthey held up; it had nothing to check, and defaulted to \"trust it\"\nsilently, every time.</p>\n<p>Under that free pass, three failures went unnoticed. One point's\ndrafted chart recorded 8 shots, evenly spaced about a second and a\nhalf apart — but the real footage runs twice as fast, a shot every\n0.9 seconds, meaning the shot-detector had missed every other stroke.\nIt still looked trustworthy because the who's-hitting-next check\nhappened to add up anyway — the same ball-tracking blind spots hit\nboth checks the same way, so the errors canceled out instead of\nstanding out. A second clip opens\non a slow crossfade into a rally already underway — five real seconds\nof tennis before the stroke the stance method called \"the serve.\"\n<a class=\"\" href=\"https://optic-yellow.com/blog/the-draft-grades-itself\">cv-15</a> had already caught this trick\nonce, on the clay-court match, where the broadcast cut hard into the\nmiddle of a point; Wimbledon's editor does the same thing with a fade\ninstead of a cut — exactly what the (silently broken) serve check\nexists to catch. And a third case: a four-shot rally in the human's\nchart where the ball-tracking data never shows a single net crossing\n— the <a class=\"\" href=\"https://optic-yellow.com/primer#hits-and-bounces\">ball's path across the net</a> is a\nrally's skeleton, and this rally had none.</p>\n<p>The fix wasn't to patch the broken check. It was to add two checks\nthat look at the whole point instead of just the serve. First: does\nthe ball cross the net, at rally speed, before the moment the chart\nclaims the serve happened? If so, the clip started mid-rally already\n— catching both the hard-cut and the fade. Second: does the charted\nshot sequence show any net crossings in the tracking data at all? No\ncrossings, no trust, however many shots the chart claims. Swapping\nthe one broken check for these two took Wimbledon's held-out score\nfrom 69% to 85%, and the project's overall number from 88% to 94%,\nwhile cutting the worst-case disaster flags roughly in half.</p>\n<p>The project already learned this lesson once, in a different\ncostume. Back in <a class=\"\" href=\"https://optic-yellow.com/blog/within-one-token-of-a-human\">cv-14</a>, the\nmachine's shot-direction calls came out backwards for a large share\nof points, because the code assumed a fixed side of the court instead\nof checking which end the receiver was standing at — nobody noticed\nuntil the scorecard itself gave the bug away. The Wimbledon check is\nthe same mistake in a different jersey: <strong>a trust score is only as\nhonest as its least-examined assumption.</strong> The project's best idea\nhad quietly been a rubber stamp on one entire broadcast. The audit\nquestion was never \"is this check smart\" — it's \"can this check even\nfire, here, on this footage.\"</p>\n<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>\n<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>\n<p>The trade for that last jump is real: 1.6 fewer points of coverage,\nfor a genuinely more honest number — the right trade every time. See\n<a class=\"\" href=\"https://optic-yellow.com/primer#confidence\">Court Vision in Plain English</a> for why coverage\nand precision have to be read together.</p>\n<p>A let-cord sensor that never buzzes isn't proof of a clean serve. It\nmight just be unplugged — and the only way anyone finds out is by\nchecking whether it could have buzzed at all.</p>\n<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\n(hard, night, AO feed). t6 = Sabalenka–Pegula, US Open final 2024\n(hard, WTA, USO feed). t7 = Djokovic–Sinner, ATP Finals 2023 (indoor\nhard, Tennis TV feed). 575 MCP points on the table, 372 clips\nextracted, 356 scored.</p><p><strong>Config-only integration.</strong> Every match ran through the existing\n<code>fitcourt</code> / <code>probe</code> / <code>extract</code> / <code>boundaries</code> / <code>align</code> stages plus\none YAML per match — no script forks. Fixes landed in <code>court_detect</code>\nconfig: t5's bright coating starved the tophat threshold default\n(raised); t6's green apron shares an HSV band with its blue court (a\nsecond hull band, OR'd in); t7's light-blue apron rides V≈252 (value\nceiling opened). Homography fit window: t5's first window carried\n26.5 px of camera pan smear, fixed via a static 5-second serve-setup\nwindow (≤0.2 px residuals).</p><p><strong>Zero-valued columns.</strong> All three broadcasters hide the score-bug at\n0-0, which splits game-start points into duplicate-score fragments.\nAdjudication: MCP's own serve columns arbitrate most groups (a human\nrow reading <code>4w</code> plus a second-serve rally means the fragment pair is\none point, serve in the later piece); the rest fell to changeover\nparity and eyeballed frame strips. Losers get blanked so the eval\nskips rather than double-charges.</p><p><strong>Tiebreak parity bug.</strong> The staged prior counted Turin's set 2 as 12\ngames + a tiebreak = 13 swap-units (odd). Wrong: a 7-4 breaker is 11\npoints with one internal end-change at 6, and the set-end change\ncancels it — even parity. The set-3 server-end vote had come back 17\nagree / 37 disagree, systematically flipped; correcting the prior\nflipped it to 37/17, verified on video.</p><p><strong>Process failure.</strong> The run stalled overnight: the background-job\nwatcher died silently between t6 ball tracking and t7 players jobs —\nboth finished fine, nothing was listening. The rest of the run polled\njobs 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\nleave-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\nagainst its chart CSV, the human's string, and frame strips. The\nlaunch-plausibility gate (cv-15's best find) only inspects ball-called\nserves; every t4 serve is stance-called; the gate was vacuous on the\nentire 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\nshots at 1.5s spacing against a real ~0.9s cadence, striker chain\nalternating cleanly because the ball track is blind in the same\nplaces the hit detector is; (2) the dissolve-cut mid-rally join — a\ncrossfade into a live rally, 5 seconds of tennis before the stroke\nthe stance detector blessed as \"the serve\"; (3) the spineless rally —\na 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\nstrokes (point_02's 17-stroke rally yields 7 weak crossings); charted\ncadence is real but soft (AUC 0.62, marginal flags 50/50) — a sidecar\ncolumn, not a model feature.</p><p><strong>What shipped:</strong> <code>xr_pre_serve</code> (rally-speed net crossings ending\nbefore the charted serve) and <code>rally_spineless</code> (a 3+-shot chart with\nno 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\ntrade was 1.6 points of coverage. Residual: <code>t4_point_11</code> stays\nflagged at 10 edits — its dissolve-cut leaves no pre-serve crossings\nbecause the track is blind there too.</p><p><strong>Final refit.</strong> The shipped scorer refit on all 491 flags 97 points\nhigh at 96% in-sample; exports regenerated for all seven matches —\n508 draft points, 99 flagged high.</p></div></div></details>\n<p>Where things stand now: seven matches, five broadcasts, 491\nhuman-charted points on the books, and a HIGH flag that means \"start\nfrom this draft\" and is right about 94% of the time on the honest,\nheld-out test — for roughly 1 in 5 points confident enough to earn\nthe flag at all. The benchmark finally has the size its own warnings\nhad been asking for.</p>\n<p>Session cost: $0.00 — three tournament finals' worth of brand-new\nanswer-key data, added for zero marginal dollars. Project total so\nfar: about $16.</p>",
            "url": "https://optic-yellow.com/blog/the-benchmark-grows-up",
            "title": "The Benchmark Grows Up",
            "summary": "A bigger test made the trust score honestly drop — then a check that could never fail got caught, and the score climbed back up.",
            "date_modified": "2026-07-11T02:00:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        },
        {
            "id": "https://optic-yellow.com/blog/the-draft-grades-itself",
            "content_html": "<p><em>That clip up top: the machine's actual draft of the Roland-Garros\nfinal, point by point — each gets a chart string, a confidence score,\nand a flag, HIGH rows glowing green. Three flagged points then play\nback with their draft on screen, from a clean ace (<code>s4*</code>: serve out\nwide, unreturned, 0.969 confidence) to a messy point full of \"not\nsure\" marks (0.933 confidence). The running tally: 59 points drafted,\n16 flagged \"start from this draft.\"</em></p>\n<p>Say a machine hands a volunteer chart-corrector a stack of drafted\npoints — how's she supposed to know, before she's watched a single\none, which drafts are worth trusting and which she should chart from\nscratch?</p>\n<p>Previously: <a class=\"\" href=\"https://optic-yellow.com/blog/within-one-token-of-a-human\">cv-14</a> gave the\nproject its report card — the <a class=\"\" href=\"https://optic-yellow.com/primer#token-edit-distance\">token edit\ndistance</a>, how many fixes an editor needs\nto turn the machine's chart into a human's, a point \"accepted\" at one\nfix or fewer. <a class=\"\" href=\"https://optic-yellow.com/blog/t1-answers-in-the-back\">cv-10</a> set the rule for\ngrading any of it: score only against matches a human already\ncharted, never a match the pipeline saw while being tuned. And since\n<a class=\"\" href=\"https://optic-yellow.com/blog/m3-who-hit-that\">cv-05</a>, players get found for free by\n<a class=\"\" href=\"https://optic-yellow.com/primer#background-subtraction\">background subtraction</a> — photograph\nan empty court, call anything different in a later frame a person.\nToday tests whether that free method is good enough, then teaches the\ndraft to flag its own reliability.</p>\n<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>\n<p>Picture two line judges. The first only calls a ball \"out\" when it\nlands a clear foot past the line. She's almost never wrong, but\nrarely speaks, leaving a player who trusts only her guessing on\neverything else. The second judge calls anything that looks close —\nshe catches nearly everything worth catching, but plenty of her calls\nare wrong on replay.</p>\n<p>A confidence flag sits on that same spectrum, and two numbers say\nwhere. <strong>Coverage</strong> is how often it's willing to call HIGH at all —\nhow often the strict judge speaks. <strong>Precision</strong> is how often a HIGH\ncall is actually right. A flag that's always right but rarely fires\nisn't useful, and neither is one that fires constantly and is often\nwrong — you need both numbers together. (<a class=\"\" href=\"https://optic-yellow.com/primer#confidence\">More in the\nprimer.</a>)</p>\n<p>Making those numbers honest takes a third idea: <strong>calibration</strong>. Take\npoints a human volunteer has already charted — the <a class=\"\" href=\"https://optic-yellow.com/primer#ground-truth\">answer\nkey</a> — and check signals the pipeline keeps\nabout its own effort: did the serve call commit cleanly? does the\nball's path have gaps? did two vote-counting methods for \"who hit\nthat\" agree? Fit those signals to the real outcomes and the fit\nbecomes a small scoring formula.</p>\n<p>The trap: a formula that peeks at a match's answers while being built\nwill always look brilliant tested on that same match — an open-book\nquiz, not a test. So fit it on three matches, then grade it, cold, on\nthe fourth it's never seen, rotating until every match gets a turn\nheld back. Tennis translation: <strong>the system never grades its own\nhomework</strong> — the same rule cv-10 set for the pipeline, now applied to\nthe flag that grades its work.</p>\n<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>\n<p>Today's first question: how much of the draft's remaining mistakes\ntrace back to the free player-finder misjudging where people stand?</p>\n<p>Every shot call — forehand or backhand, roughly which third of the\ncourt — comes from one rule: whichever side of the player's outline\nthe ball arrives at, that's the shot. A bad outline poisons the call,\nalready the biggest single source of remaining edits, about 1.7\ncorrections per point. Sorting every scored shot by outline quality at\ncontact:</p>\n<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>\n<p>Nearly half of every shot call — 45% — was riding on a shaky or\nmissing outline; accuracy was 71% on clean outlines, 39% on shaky\nones. Ceiling: about 26 more correct calls.</p>\n<p>Free cleanup — rejecting teleporting outlines, checking court-half,\nfilling short gaps, widening for legs-only silhouettes — bought back 3\nof those 26: about an eighth of the ceiling. The rest was missing\ndata, not noisy data: the far player usually isn't in the free\ntracker's output near contact. No cleanup invents a player the tracker\nnever saw.</p>\n<p>That earned the right to spend money: SAM 3 — the promptable AI that\ntracked this project's first ball, back in\n<a class=\"\" href=\"https://optic-yellow.com/blog/m0-first-tracked-rally\">cv-02</a> — re-found both players in the\nRoland-Garros match only, prompted from the free tracker's own boxes,\nfor about $12. Shot-call accuracy went from 59% to 67%, mostly\noutlines that had been missing — 21 calls flipped right, one point\ngaining seven alone.</p>\n<p>It still didn't move the number that matters — full points a\nvolunteer would sign off with one fix or fewer, stuck at 3 of 59. A\nbetter shot call wasn't holding those points back, and the paid\ntracker inherited the blind spot it was hired to fix: where the free\ntracker drew no usable box, an automatic prompt had nothing to point\nat either. Verdict: the $0 tracker stays default — what every\nsingle-piece upgrade has said since cv-14: improving one ingredient\nnudges the score without changing which points a human would accept.</p>\n<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>\n<p>Before any of that shipped, this project's first fourteen posts'\nworth of scattered experiment scripts — twenty-odd files, four\nnear-identical chart-builders kept in sync by hand — got folded into\none package: one chart-builder, one grading tool, one command-line\ntool, broadcaster differences moved into a settings file per match.\nCondition for merging: it had to reproduce every number this series\nhas published, byte for byte, before anything new shipped on top. It\ndid, down to 138 chart files and four scorecards.</p>\n<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>\n<p>With the free player-finder settled and the tool consolidated, the\nactual flag got built — the scoring formula above, trained on the\npipeline's self-reported effort and graded the\nnever-grades-its-own-homework way.</p>\n<p>Two surprises turned up. Aces — serves the returner never touches —\nwere scored as if the machine had refused every shot in the point,\nsince there are no rally shots to refuse. Backwards: an ace is the\ncleanest point in the match, punished for being short. Fixed by\nteaching the formula that no shots after the serve means the point\nended cleanly.</p>\n<p>Stranger: on the Roland-Garros match, the broadcast editor sometimes\ncuts into a rally already in progress. The serve-detector, seeing the\nball cross the net for the first time in the clip, calls that crossing\n\"the serve\" — even though a real serve toss lands 20+ meters past the\nbaseline, and this one launched from inside the court. That's a clip\nthat joined mid-rally, not a serve. Teaching the formula that one tell\ntook held-out reliability from 65% to 84%.</p>\n<p>One more result belongs here because it failed: a stricter flag —\n\"sign off without watching the point at all,\" 85% accurate or better.\nTested the same honest way, it landed at 50% precision, a coin flip,\nwilling to call only 1.5% of points that confident. There aren't\nenough graded points to support it: of 135 hand-charted points, only\nabout 1 in 9 already lands within two corrections. That tier is dead,\nreported dead rather than dropped quietly — a confidence system that\nhides its own failures isn't one anyone should trust.</p>\n<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>\n<p>What did the shipped flag earn, tested the honest way? Read the pooled\nnumber the way you'd read a strong first-serve percentage — not a\nguarantee, but a number worth building on:</p>\n<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>\n<p>HIGH means \"start from this draft\" — 93% of the time, held out, it\nneeds five corrections or fewer. It doesn't mean the draft is already\nright: only about a quarter of HIGH points need two corrections or\nfewer. Missing HIGH means \"expect heavy correction\" — more than half\nof those need six or more, so treat it as a hint, not a finished\nchart. The Roland-Garros match stays the shakiest, not because its\nmath is worse — its disease is missing footage, shots the editor cut\nbefore the camera ever saw them, which no signal can detect from film\nthat doesn't exist.</p>\n<p>Said the way a tennis fan would at dinner: a confidence flag only\nmeans something once it's been tested on points it never got to see\nthe answers to first.</p>\n<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\nball reaching the box; biggest post-direction edit sink, 1.71\nedits/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\nscored.</p><p><strong>Free hygiene</strong> (tuned on t3, t1/t2/t4 held out): court-half\nplausibility, x-only teleport rejection, gap interpolation, letter\ngate widened to clip-typical body height. Net: 114/209 → 117/209.\nThree other fixes were built, measured, and removed (not detailed\nfurther here).</p><p><strong>SAM-3 buy test.</strong> t3 = Roland Garros 2023 final (Djokovic–Ruud,\nclay), 59 clips; prompts auto-derived from the bgsub boxes, ~$12\nall-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).\nAcceptance stays 3/59. Default stays bgsub at $0.</p><p><strong>Consolidation.</strong> <code>courtvision/</code> package: one chart assembler, one\neval, one CLI; per-broadcaster differences moved into\n<code>data/matches/&lt;id&gt;.yaml</code>. Gate: 138 chart CSVs byte-for-byte, 4\nscorecards byte-identical, acceptance 7/135 untouched. <code>experiments/</code>\nfrozen as history. One command: <code>courtvision draft t3</code> → chart CSVs →\nconfidence per point → <code>outputs/t3/export/t3_mcp_draft.csv</code>.</p><p><strong>Confidence layer.</strong> Per-point signals — serve commit and stance\nmargin, striker-chain conflicts, ball coverage and holes, refusal\nfractions, the direction signal-tier ladder, crossings-vs-shots\nconsistency — feed a small logistic regression (numpy), calibrated\nleave-one-match-out (LOMO) against token edit distance: fit on 3\nmatches, score the 4th, rotate.</p><p>Aces bug: a 100%-refusal charge for zero rally letters to refuse.\nMid-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\n11.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,\npoint number, score state, server), machine string in the 1st-serve\ncolumn, plus <code>confidence</code>, <code>conf_p</code>, clip name, jump-to timestamp\n(<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\nand transcribed score-bug alignment, a draft for every tracked point\nat $0 marginal cost, HIGH on ~1/3 at 93% held-out reliability. Not\npromised: a HIGH point is right, a LOW point is chartable without\nvideo, or anything about rallies cut before broadcast. North star:\nacceptance ≤1 token edit = 5.2% (7/135), ≤5-edit curve = 57.0%.</p></div></div></details>\n<p>None of this moves the project's real ceiling, unchanged since cv-14:\nonly 5.2% of drafted points — about 1 in 20 — are an exact-or-one-fix\nmatch to a human chart, and even counting anything within five\ncorrections as close enough, that's still just 57%. The ceiling isn't\nstuck on any piece of the pipeline anymore — it's stuck on the answer\nkey, only 135 hand-charted points, barely 1 in 9 of them already\nwithin two corrections. More matches are already being lined up, and\nthe moment they land, every number here gets recalculated against a\nbigger, harder answer key.</p>\n<p>Session cost: about $12 — the SAM test, under a one-time $30 limit —\nthen $0.00 for everything built after it. Project total: about $16.</p>",
            "url": "https://optic-yellow.com/blog/the-draft-grades-itself",
            "title": "The Draft Grades Itself",
            "summary": "A confidence flag tells a volunteer chart-corrector which draft points to trust — and this post checks whether the flag itself can be trusted.",
            "date_modified": "2026-07-10T02:00:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        },
        {
            "id": "https://optic-yellow.com/blog/within-one-token-of-a-human",
            "content_html": "<p><em>That clip up top: an ad-point rally from the Djokovic–Ruud Roland Garros\nfinal, just under six seconds of video, with the machine's chart building\nitself token by token above the line a human volunteer wrote for the same\npoint. Four pieces light up green, one after another, because the machine\nand the human agree on every one of them: a second serve landing in the\nbody of the box, then three backhands in a row — two to the same spot,\nthen one that breaks the other way. Then the last ball dies in the net.\nThe human's shorthand for that is the letter <code>n</code>; the machine's chart\nshows a different mark instead, <code>*</code>, so that last piece glows amber — one\ndisagreement. Under the rule this post is about, one disagreement is\nstill close enough to count: the point is <strong>accepted</strong>. The video's\nclosing screen turns the whole story into four bars.</em></p>\n<p>Here's the question this post is finally built to answer: if you handed\nthe machine's chart of a point to the volunteer who charted that same\npoint by hand, how many things would she have to fix before she'd sign\nher name to it?</p>\n<p>Previously: last time (<a class=\"\" href=\"https://optic-yellow.com/blog/the-score-bug-is-the-point-id\">cv-13</a>), the\nsoftware learned to read the small scoreboard graphic in the corner of\nthe screen. That graphic stays pixel-for-pixel identical for the whole\npoint and changes only the instant a point ends, so reading it let the\npipeline do two things: glue video clips back together into whole points,\nand look up the exact matching point in a human volunteer's hand-charted\nrecord of the same match — the <a class=\"\" href=\"https://optic-yellow.com/primer#ground-truth\">ground truth</a>\nthis whole project has been grading itself against since\n<a class=\"\" href=\"https://optic-yellow.com/blog/t1-answers-in-the-back\">cv-10</a>. That match-up is what makes this\npost possible: for the first time, every point the machine drafts can be\nchecked, one for one, against what a real person wrote down for that\nexact point. Before today, the software also had a specific way of\nfinding hits and bounces — watching for the moment the ball's path bends\nbackward on itself, <a class=\"\" href=\"https://optic-yellow.com/primer#hits-and-bounces\">the court doesn't return\nserve</a>, from\n<a class=\"\" href=\"https://optic-yellow.com/blog/m2-hits-and-bounces\">cv-04</a> — a rule that mostly worked but\nquietly missed shots hit far from the camera.</p>\n<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>\n<p>Think about how a coach would grade a rookie scout's notes on a match.\nThe scout writes down what happened, point by point, in shorthand. The\ncoach already has her own notes on the same match. She doesn't grade\nshot by shot — she reads the scout's line for a point next to her own\nline for that same point and asks one blunt question: how many words\nwould I have to cross out, change, or add to turn the scout's version\ninto mine?</p>\n<p>That's exactly what this post's headline number measures, except the\n\"shorthand\" is real tennis charting notation and the \"coach\" is a\nvolunteer from the Match Charting Project — the same <a class=\"\" href=\"https://optic-yellow.com/primer#ground-truth\">ground\ntruth</a> this project has been grading itself\nagainst. Write a chart of one point as a list of pieces, or tokens: the\nserve and where it landed, then each shot's stroke and direction, then\nhow the point ended. Do that for the machine's version and the human's\nversion of the same point, then count how many of those pieces would\nneed to be changed, added, or removed to turn one list into the other.\nThat count is called <strong><a class=\"\" href=\"https://optic-yellow.com/primer#token-edit-distance\">token edit distance</a></strong>,\nand it's now the north-star number for the whole project — the honest\nanswer to \"would a human sign off on this.\"</p>\n<p>A point counts as <strong>accepted</strong> when that count is one or less — the kind\nof chart a charter would wave through after fixing at most one thing.\nAnd there's a rule that keeps the machine honest: if it isn't sure and\nwrites a \"no idea\" placeholder instead of guessing, that placeholder\nstill has to be fixed later, so it costs exactly as much as getting the\nanswer wrong. Shrugging is not a way to dodge a mistake.</p>\n<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>\n<p>Before any of that grading could happen, the event detector needed work.\nThe old approach hunted for a kink in the ball's flight path — the\nmoment its direction snaps backward, which is <a class=\"\" href=\"https://optic-yellow.com/primer#hits-and-bounces\">how a hit differs from a\nbounce</a> — but a kink from a shot on the far\nside of the court, seen from a raised broadcast camera, can shrink to a\nsingle wobbling pixel that's easy to miss entirely. So the detector got\nrebuilt around a blunter, more reliable piece of tennis sense: a rally in\nprogress sends the ball across the net every single shot. Instead of\nsquinting at every wobble, the software now counts how many times the\nball crosses the net and adds one for the serve — and that crude count\nalone, before any fine-tuning at all, already beat the entire old\nwobble-hunting detector at guessing how long a rally ran. The rebuilt\ndetector went on to sharply improve how often it read stroke letters\ncorrectly, though one older test clip actually got worse in the process —\na small, honestly reported step backward, not swept under the rug\n(exact numbers in the appendix).</p>\n<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>\n<p>With the rebuilt detector in place and every point matched to its human\noriginal, the project could finally run the grading question for real —\nacross four matches, three court surfaces, both the men's and women's\ntours: how many of 135 points would a volunteer charter sign off on with\nat most one fix?</p>\n<p>The answer: <strong>3 points out of 135 — 2.2%.</strong> Before the detector rebuild,\nusing the old event logic, that number was zero. Two percent isn't a\ngood score. But it's the <em>first honest</em> score — a real distance to the\ntop of the mountain, measured in a unit a volunteer charter actually\ncares about, instead of a pile of separate stats that could all be\nquietly improving while the finished chart is still wrong.</p>\n<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>\n<p>A 2.2% pass rate means the typical point needed about seven fixes, on\naverage, to become the human's version — so the project broke that\naverage down by fix type to see where the effort was going. Missing\nshots — a shot the human charted that the machine's chart skipped\nentirely — were the single biggest source. But close behind, and the\nmost fixable-looking, was shot <strong>direction</strong>: which way a shot traveled,\nnot just what stroke it was. Getting the direction digit wrong, whether\nalone or combined with the wrong stroke letter, accounted for 39% of\nevery fix an editor would have to make — the largest chunk of the whole\nedit budget. And the direction guesses weren't just wrong often: the\nmachine only ventured a direction guess on 70% of shots, and when it\ndid, it was right just 48% of the time — barely above the 33% you'd\nexpect from picking one of three directions at random.</p>\n<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>\n<p>That's a big enough number to be worth chasing, and the cause turned out\nto have nothing to do with cameras or vision at all — it was a\ndefinition problem. The Match Charting Project's own instructions define\none shot direction as \"a right-hander's forehand side\" and add, in\nparentheses, \"a lefty's backhand.\" Read carelessly, that sounds like\ndirection depends on which hand hit the ball. It doesn't: the\nparenthetical is the tell. Direction is defined relative to a fixed side\nof the court — specifically, the half that's <em>receiving</em> the shot — and\nit only happens to line up with \"forehand side\" for one particular kind\nof player. The pipeline's code, though, had been computing direction\nusing fixed court thirds, as if the same physical side of the court\nalways meant the same direction: correct by pure coincidence for a shot\ngoing to the far end, backwards for a shot going to the near end. It was\na compass mirrored for half the court the whole time. Once the\ndefinition got fixed — direction read relative to which half was\nreceiving, instead of a fixed physical side — direction accuracy jumped\nfrom 48% to 75%, the machine started venturing a guess on 97% of shots\ninstead of 70%, and overall acceptance nearly doubled: from 3 points out\nof 135 to 7, from 2.2% to 5.2%.</p>\n<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>\n<p>Four matches, three surfaces, both tours — here's how many of each\nmatch's points a human charter would sign off on with at most one fix,\nafter the compass fix went in:</p>\n<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>\n<p>That's up from 3 of 135 (2.2%) before the direction fix. The honest low\npoint on the board is Wimbledon: zero points accepted, because its\nproblem isn't a wrong direction digit — it's shots that shouldn't be in\nthe chart at all, or shots missing from it (more in the appendix).</p>\n<p>The takeaway a tennis fan could repeat at dinner: what matters isn't how\nmany little stats are climbing, it's how many marks a red pen still\nneeds — and fixing one wrong assumption about which way \"direction\"\npoints nearly doubled how many points needed no more than one.</p>\n<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\nball-first — the serve is the track's first sustained net crossing, its\ndirection names the server's end — and clay server-end accuracy went\n7/58 to 48/59 (12% → 81%), grass 17/49 to 38/49, with the honest asterisk\nthat grass refuted the ball signal outright (white ball, white lines:\n26/49, a coin flip), so the grass detector kept the player-based signal\nand only the search window learned to slide. Then the coda pass went\nhunting for a per-event tell that separates a live rally from\nWimbledon's dead-ball aftermath, and refuted every candidate — landing\npositions, walking speed, coverage, all byte-similar between the two —\nuntil the rule that held was sequence-level tennis: a live rally sends\nthe ball across the net every shot.</p><p>That sentence turned out not to be a truncation rule; it was the\ndetector, inverted. The old design — M2-era cusp classification (the\nwobble-by-wobble hit/bounce guesser from cv-04), tuned on sparse SAM\ntracks (the ball-tracking AI's own thin, gap-ridden paths) — inspected\nevery wobble in the ball track and asked \"hit or bounce?\" Diagnosing all\n57 rally-count misses (not a sample) found the far-end miss systematic:\na far hit at 250+ px is a ~1 px/frame wiggle no near-tuned gate can feel,\nbut its flight crosses the net at full projected speed. The keystone\nmeasurement, taken before any design was written: raw net-crossing count</p><ul>\n<li class=\"\">1 already beat the entire old detector — rally ±1 of 36/59 vs the\nchart's 25/59 on clay, 36/49 vs 26/49 on grass, from just counting\ncrossings.</li>\n</ul><p>So v5 stops classifying wobbles. Net crossings are the rally skeleton:\nthey partition the point, each partition contains exactly one hit, and\nthe cusp machinery is demoted from counting shots to locating the hit\ninside its slot. Constants tuned on t3 (the clay match) only, everything\nelse scored untouched — the tuned tree went 42% → 61% on rally length\nwhile letters (aligned) tripled its base, 19/24 → 65/85. The named\nregression: t1 (the night match) fell 16/22 → 13/22. The skeleton needs\ncrossings, and the night reel's thin low-recall tracks amputate rallies\nthe old cusp counter used to pad out. A design that leans on the net\npays the price wherever the track can't see the ball cross it.</p><p><strong>Edit-distance mechanics.</strong> Tokenize both strings as\n<code>[serve+zone][letter+direction]*[ending]</code> and accept a point iff the\nmachine's list is within one token edit of the human's — strict\nequality, a <code>?</code> matches nothing, so refusing to commit costs exactly\nwhat being wrong costs. First reading, all four matches: 3/135, 2.2%\n(before v5 it was 0/135). Mean token edits per point: 7.18 across n=135,\nbacktraced 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\npoint — 39% of the entire edit budget — attempted on only 70% of shots\nand 48% right when attempted, barely above the 33% floor. Meanwhile the\neffort curve says the skeleton v5 just rebuilt is no longer the wall:\nstructure alone (shot count + letters) would already accept 23.7% of\npoints at the same ≤1 bar, per the original decomposition note (this\nfigure is carried over as originally reported alongside the ≤3-edit\ncurve below; the two are not obviously the same slice of the data, and\nthat ambiguity is preserved rather than resolved here). The headroom\ntable, 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\naxes at once — but the compounding is steep and ordered, directions are\nthe biggest single lever, and the tell is in the fine print: perfecting\nonly the <em>attempted</em> directions is worth almost nothing. The sparsity\nand the accuracy are the same disease.</p><p><strong>The mirrored compass.</strong> MCP's instructions define direction 1 as \"a\nright-hander's forehand side (a lefty's backhand)\" — the parenthetical\nis the tell. That sentence names a fixed side of the receiving half,\nmirrored by which end is receiving, not flipped by who's holding the\nracquet. <code>zone()</code> had been coding absolute court thirds: correct for a\nfar right-handed receiver by coincidence, mirrored for every near\nreceiver. Half of \"barely above the random floor\" was a sign error.\nEvery plausible mapping went into a calibration table before any code,\nand the both-lefty t1 match is what adjudicated the handedness question:\nreceiver-end mirror without a handedness flip scores 11/24 on the\nlefties; adding the flip drops it to 5/24 — the naive reading of the\nspec loses to its own parenthetical.</p><p>On top of the fixed compass, a measured signal ladder gives every shot a\ndirection now — the workhorse is the receiver's next contact (where the\nball was received is where this shot went; 77% right, 81% coverage on\nthe dev match), backed by the near-half bounce (86%) and the v5 skeleton\nmoonlighting as a crossing-plus-slope extrapolator. Even refusal policy\nwas an experiment, not a principle: vetoing on disagreement kept\nprecision flat while shedding net-right tokens, and since the metric\nprices a shrug the same as an error, commit-always won.</p><p>Direction attempts went 70% → 97%, accuracy when attempted 48% → 75% —\nheld-out matches gaining right alongside the tuned one — and acceptance\nmoved 3/135 → 7/135, with the ≤5-edit curve jumping 41.5% → 57.0%. Every\nother metric on all four scorecards is byte-identical, exactly the claim\na direction-only change should be able to make. The meta-lesson joins\nthe permanent file: when a component scores near the random floor, check\nthe semantics before rebuilding the perception. A 27-point accuracy jump\nwas sitting in a coordinate convention the whole time.</p><p><strong>Full scorecard.</strong> Four matches, three surfaces, both tours, 168 points\naligned to human charts as originally reported for this section (a\ndifferent count from the n=135 used above for the token-edit\ndecomposition — 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\nstrict — its disease is structure (1.24 phantom insertions plus\ndeletions per point), and no direction-digit fix repairs a token that\nshouldn't exist in the first place. t3's (clay, Roland Garros) missing\nshots are mostly the broadcast's fault, not the pipeline's — the RG\neditor cuts into rallies mid-point — and structure caps the\nall-components ceiling at 44%. The re-run decomposition already names\nthe next levers in order: structure, letters, endings — one token per\npoint, 30% right, as originally reported.</p></div></div></details>\n<p>Two percent was the first honest look at how far this project has to\nclimb. Five percent, after fixing the compass, is the second look. The\nreal change isn't the number itself — it's that the climb now has\narithmetic behind it instead of a guess.</p>\n<p>Session cost: $0.00. Project total so far: about $4.15 of a $9 budget.</p>",
            "url": "https://optic-yellow.com/blog/within-one-token-of-a-human",
            "title": "Within One Token of a Human",
            "summary": "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%.",
            "date_modified": "2026-07-10T01:45:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        },
        {
            "id": "https://optic-yellow.com/blog/the-score-bug-is-the-point-id",
            "content_html": "<p><em>That clip up top: a 21-second, sixteen-shot Djokovic–Ruud rally from\nthe Roland Garros final. The old segment-finder had chopped it into\nfour broken pieces — the tick marks on the timeline are where those\nold cuts fell, and each piece charted only 1 to 3 shots alone. This\npost's method proved all four belong to one point, glued them back\ntogether, and the same shot-detector read the reunited rally as 12 of\n16 shots correctly.</em></p>\n<p>Every TV director cuts away sometimes — a reaction shot, a replay, a\ndifferent angle. So how do you prove, from footage alone, that two\nclips are really the same point? Two test matches weren't enough, so\nthis session added harder ones: the 2023 Roland Garros final on clay,\nand the 2024 Wimbledon final on grass. Four matches now, three\nsurfaces, both tours, about 108 new points checked against a human's\nchart, for zero dollars. What broke wasn't one narrow piece — it broke\nthe assumption under every number on the board, and the fix had been\non screen all along.</p>\n<p>Previously: <a class=\"\" href=\"https://optic-yellow.com/blog/t1-answers-in-the-back\">cv-10</a> gave the project its\nfirst honest report card, grading its draft charts against a match a\nhuman had already charted by hand. <a class=\"\" href=\"https://optic-yellow.com/blog/the-zero-dollar-specialist\">cv-12</a>\nthen swapped in cheaper, more reliable ball-tracking software — and\nuncovered an accident: the old tracker's habit of <em>losing</em> the ball\nwhen a rally ended had secretly been flagging \"this point is over.\"\nThe new tracker never loses the ball, so that free signal vanished,\nleaving the project needing a real point-boundary detector.\n<a class=\"\" href=\"https://optic-yellow.com/blog/m3-the-cutting-room\">cv-06</a> had already learned a broadcast is\nmostly not rally footage — finding the stretches that even show the\ncourt took its own detective work.</p>\n<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>\n<p>Picture a broadcast with the sound off. Almost everything keeps\nchanging — players move, the camera pans, the crowd shifts — except\nthe scoreboard graphic in the corner. It isn't part of the court; it's\nstamped on top of the picture like a watermark, so a pan can't nudge\nit. And it obeys a rule nothing else on screen does: frozen, pixel for\npixel, for the whole length of a point, changing at exactly one\nmoment — when the point ends.</p>\n<p>That discipline is what this series calls <strong>the score bug as\nmetadata</strong> (<a class=\"\" href=\"https://optic-yellow.com/primer#score-bug\">more in the primer</a>): the graphic,\njust by sitting there frozen, quietly carries the match's structure.\nTwo things fall out of it for free. It's a point ID — two chunks of\nvideo showing the identical frozen scoreboard are fragments of the\nsame point, however the editor cut between them, and can be safely\nstitched together. And it's a join key — a shared label matching this\nclip to its point in the human charter's own record.</p>\n<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>\n<p>The clay final (Djokovic vs. Ruud) broke the court-mapping recipe that\nworked fine on hard courts. Turning the TV picture into a court\ndiagram needs the <a class=\"\" href=\"https://optic-yellow.com/primer#homography\">homography</a> — the\nscreen-to-court conversion recipe — and its usual tricks assume a blue\nhard court with crisp white lines. Clay has neither: the \"find the\nblue court\" trick found nothing, and the trick that erases players by\nblending frames together smeared the faint lines into the dirt\ninstead. A clay-specific rebuild (appendix has the method) got it\nworking again, accurate to a fraction of a pixel.</p>\n<p>The grass final (Krejcikova vs. Paolini) broke the <em>check</em> on the\nrecipe instead: the software found a court mapping whose lines lined\nup neatly with each other — and it was wrong, like an umpire\nconfidently announcing a plausible score that isn't the real one.\nThat's an impostor fit. The fix: instead of \"do the few lines this\nmapping used line up?\", ask \"do <em>all nine</em> of the court's real lines\nland where this mapping says they should?\" A wrong mapping explains a\nfew lines and leaves the rest floating; the real one explains all\nnine. (One more check earned its keep on this match too, following\n<a class=\"\" href=\"https://optic-yellow.com/blog/t1-right-hardware-wrong-sign\">cv-11</a> — the appendix has it.)</p>\n<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>\n<p>Both matches mapped, the draft charts still came back mediocre — for a\nreason underneath every number, not inside any one of them. The\npipeline assumed one unbroken stretch of court-view video (a\n<em>segment</em>) equals one point. That breaks in opposite directions,\ndepending on who's holding the editing scissors.</p>\n<p>Roland Garros cuts away mid-rally, chopping one point across several\nsegments: the draft charted just 3.9 shots per point here against the\nhuman charter's real 8.9. The rally was there; the video just told it\nin pieces. Wimbledon does the opposite, almost never cutting between\npoints, so one fifteen-second stretch can hold two or three back to\nback — the draft overcounted there, 8.1 shots per point against the\nreal 7.2, stringing separate points into one fake rally.</p>\n<p>The two earlier test matches had simply gotten lucky: their\nbroadcaster cut almost exactly once per point, splitting the match for\nfree — the same kind of accident as the old tracker's habit of losing\nthe ball, doing a job nobody built it to do.</p>\n<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>\n<p>This is the gap the score bug closes. Two chunks of video with the\nidentical frozen scoreboard, however the editor cut between them, are\nthe same point — merge them. One chunk where it changes partway\nthrough is secretly two points — split it there. Read the sequence of\nchanges and you get the real point-by-point structure, regardless of\nwhere the editor cut. It held with a wide, safe margin between \"same\npoint\" and \"different point\" readings, and a handful of broadcaster\nquirks that tried to break it — a scoreboard that grows after every\nset, a tiebreak banner that shoves it sideways, a fade-in that could\nfake a change that never happened — all got caught by looking closely\nat the pixels (appendix has the numbers).</p>\n<p>One theory got tested and killed outright, worth saying since it\ncontradicts what I'd guessed going in: I suspected slow-motion replays\nwere sneaking duplicate points into the count. They aren't. Every\nsuspicious duplicate score turned out to be fragments of one rally, or\na genuinely different point that passed through the same score — deuce\nrecurs, and the scoreboard alone can't tell those visits apart, only\ntheir <em>order</em> can. The last suspect was cleared by physics: the ball's\nown falling motion matched real-time gravity, and a slow-motion replay\nwould fall roughly four times slower.</p>\n<p>With segments stitched into points correctly, the clips lined up\nagainst the human record almost perfectly.</p>\n<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>\n<p>Matched row for row against the <a class=\"\" href=\"https://optic-yellow.com/primer#ground-truth\">human-charted</a>\nrecord — the independent answer key this project grades itself\nagainst:</p>\n<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>\n<p>The two rows that went down are real, worth naming rather than hiding:\nmerged clips can start mid-rally, so the software falls back to a\nweaker clue (simple alternation) for who served and how it ended. A\nfair trade for getting the boundaries right, but a real cost. Where\nthe merges had footage to work with, the payoff showed: the 16-shot\nrally up top went from four broken pieces to one point charted 12 of\n16 correctly, and another clay point came back a perfect 12 of 12.</p>\n<p>Two problems stay open, neither a scoreboard problem anymore. On clay,\nthe editor sometimes cuts into a rally already in progress, so some\nearly shots were never filmed — the scoreboard proves the clip is one\npoint, but can't invent missing footage. On grass, 19 clips still\ncount as two points when they're really one, because Wimbledon's dead\ntime sits <em>inside</em> that same frozen reading (mechanism in the\nappendix). Both are chart-reading problems now, not video-editing\nones — and they're next.</p>\n<p>The least glamorous graphic on the screen — a tiny scoreboard nobody\nwatches on purpose — is the one witness every broadcaster in the world\nagrees to keep rolling at all times, which makes it the most\ntrustworthy thing in the picture.</p>\n<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\nhomography died three ways — \"blue court hull\" has no blue, and the\nclay-hue mask swallows the frame (crowd skin ≈ clay hue); median plate\nsmears lines into dirt (<del>16 px pan, white-on-orange loses to\nwhite-on-blue); far baseline buried (V</del>200 on V~195 clay). Rebuild:\nECC-stabilized plates, thin-bright tophat filter, verticals labeled by\nsymmetry (courtside Perrier crates give 20 Hough segments inside the\nhull; junk is asymmetric, lines aren't), fit basis swapping the buried\nbaseline for the far service line. Result: sidelines 0.4/0.5 px,\ncenter line 0.2 px.</p><p><strong>t4, grass/WTA</strong> (Krejcikova–Paolini, Wimbledon feed): a\nwrong-but-self-consistent line assignment fit its own intersections to\nrms 1.7 px. Fix: score candidates by how well all nine model lines\nland on the observed mask — impostors strand the lines they didn't\nuse.</p><p><strong>Handedness</strong>: MCP's metadata listed Vondrousova, a well-known\nleft-hander, as right-handed. Post-cv-11 policy: never trust the\nchart'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\nwritten off by the staging pass as replays.</p><p><strong>Score bug validation</strong>: same-point crops differ by ≤0.005\nchanged-pixel fraction, different-score crops by ≥0.033 — a 6x margin.\nTraps: the RG bug grows a column per set (digits slide ~27 px right);\nthe set-1 tiebreak banner drops it 14 px and drifts; a reference frozen\non a fade-in frame lets compression noise fake a change 100 frames\nlater. Fix: frozen reference per plateau, anchored on the median of a\nsettling window.</p><p><strong>Replay hypothesis, refuted</strong>: every duplicate-score group was\nfragments of one rally or distinct deuce points (a 40-40 plateau sits\nbetween \"duplicate\" 40-AD clips: three deuce cycles; order carries\nwhat value alone can't). Cadence suspect acquitted: WASB-track ball\ngravity is unimodal across all 116 clips; 2x slow-mo would sit ~4x\nlow.</p><p><strong>Remaining gaps, mechanism</strong>: t4's 19 over-counted clips fail because\nWimbledon's dead time lives inside the point's own plateau, the dead\nball stays inside the court-y envelope, and the freeze-#3 gate can't\ntouch it — the t1 dead-ball coda in a new costume.</p></div></div></details>\n<p>Session cost: $0.00. Project total: about $4.15 of a $9 budget. The\nbenchmark is now four matches, three surfaces, both tours — and every\npoint ID in it comes from the one witness every broadcaster agrees to\nkeep on screen.</p>",
            "url": "https://optic-yellow.com/blog/the-score-bug-is-the-point-id",
            "title": "The Score Bug Is the Point ID",
            "summary": "A TV scoreboard graphic nobody notices turns out to be the only reliable way to tell where one point ends and the next begins.",
            "date_modified": "2026-07-10T01:30:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        },
        {
            "id": "https://optic-yellow.com/blog/the-zero-dollar-specialist",
            "content_html": "<p><em>That clip up top: the same Federer rally, tracked two different ways.\nLeft, the old general-purpose tracker's trail of yellow dots — a\ncomet's tail — thins out and dies partway through; it wrote down only\nsix of twelve shots. Right, a tiny tennis-only specialist's trail stays\nsolid to the last shot. Same clip, same notation software underneath\nboth — the only thing that changed is who's watching the ball. The\nspecialist charted all twelve shots, and got every one of the seven\nshot symbols it committed to right.</em></p>\n<p>Before spending another dollar chasing a tennis ball across the screen,\nI stopped and asked a simpler question: has somebody already solved\nthis?</p>\n<p>Here's where things stood. The pipeline had just been graded for the\nfirst time against a real match a human had already hand-charted — an\n<a class=\"\" href=\"https://optic-yellow.com/blog/t1-answers-in-the-back\">out-of-sample test</a>, rules frozen before\nanyone saw the answers, on a night match between Nadal and Shapovalov.\n<a class=\"\" href=\"https://optic-yellow.com/blog/t1-right-hardware-wrong-sign\">That test</a> caught a mirror-image\nbug — forehand and backhand swapped, because the software assumed a\nright-handed player against two lefties — fixed with a tennis fact, not\nnew vision code. A second test was already promised: a sunlit day\nmatch, Federer against Haase. Every bit of ball-tracking so far had\nbeen done by SAM 3, a general-purpose \"find that object\" AI that needed\na box drawn around the ball by hand, and that charged a small fee per\nclip.</p>\n<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>\n<p>Picture two people you could hire to watch the ball for you. One is a\ntouring pro: capable of almost anything, but before every point you\nhave to walk over and tell them exactly what to watch. The other has\nhad one job their whole career — they've stood on the same line,\nwatching where the ball lands relative to it, thousands of times a day\n— and never once needed a pep talk first.</p>\n<p>SAM 3 is the touring pro. WASB, the program introduced in this post, is\nthe line judge: about the size of a phone photo, and it has only ever\nlooked at tennis balls. Show it a photograph and it lights up how\nlikely the ball is to be at each spot on screen, the way a heat map\ndoes, and picks the brightest spot, fresh, thirty times a second, no\nmemory of the last one. That's <strong>a tiny specialist versus a huge,\npromptable generalist</strong> — more at <a class=\"\" href=\"https://optic-yellow.com/primer#wasb-vs-sam\">Court Vision in Plain\nEnglish</a> — and it's what this post turns on. One\nside effect matters later: with no memory, WASB can't <em>lose</em> the ball\nthe way SAM can. SAM carries the ball forward frame to frame —\n<a class=\"\" href=\"https://optic-yellow.com/primer#detection-vs-tracking\">tracking, in the technical sense</a> — and\na tracker that loses its grip stays lost. WASB never had a grip to\nlose.</p>\n<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>\n<p>An evening of careful research, checking every claim against more than\none source, settled two things. First: finding the ball in a tennis\nbroadcast is already a solved, free, open problem — a specialist almost\nexactly like WASB is out there, pretrained and downloadable today. Our\nown SAM-based tracking had been reinventing that wheel, worse, for a\nsmall fee on every clip.</p>\n<p>Second, and this matters more: every system like that stops <em>before</em>\nthe notation. None of them read a broadcast and write out who hit what,\nwhere, in the shorthand a human charter would recognize. That gap — not\nthe pixels, the grammar — is the real target here. Somebody wants it\nfilled, too: the volunteer project supplying our human answer keys runs\ntoday on just 32 unpaid, part-time people, and its founder has publicly\nasked for more help.</p>\n<p>One more gap we could close ourselves: nobody had published a\nhead-to-head test of a generalist against a specialist on a ball this\nsmall and fast. So we ran one.</p>\n<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>\n<p>We wired the specialist's ready-made weights into the pipeline and left\nthe notation-writing software untouched, so any difference in the\noutput could only be the tracker's doing. It won almost everywhere it\nmattered — numbers below. It also did something SAM structurally\ncouldn't: thirteen night-match clips never got a usable starting box for\nSAM, so SAM tracked zero frames of them. WASB needs no starting box at\nall — it tracked all thirteen anyway, 73–97% of frames each, no\nclicking, no cost.</p>\n<p>Then something broke.</p>\n<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>\n<p>On the night match, the number this project cares about most — shots\ncounted in a point, checked against the human's count — got <em>worse</em>\nwith the better tracker. Before the swap it had seven of eleven points\nright; after, only five. Every miss counted too many shots, never too\nfew.</p>\n<p>I went back to the photographs. Match point: the ball resting at the\nframe's bottom edge, the crowd already on its feet,\nmid-standing-ovation. A dead ball bouncing quietly in the corner while\nboth players stood around between points. A ball drifting through the\ncrowd above the back fence, long after the point ended. In every case,\nthe point was over. The ball-finder was not.</p>\n<p>Here's the mechanism. SAM tracks — it locks onto one ball and remembers\nit. When a rally ended and the ball sailed out of frame or vanished\ninto the crowd, SAM's memory broke: its trail of dots died, right\nthere, at the end of the point. Nobody designed that as a signal, but\nthe software downstream — the part that turns ball positions into a\nchart — had quietly been leaning on it anyway, never told in so many\nwords when a point was over; it had just been reading SAM's dying trail\nas the whistle. WASB never gets lost, because it never had hold of the\nball to begin with — it just keeps finding it, dead ball and standing\ncrowd and all, and the chart-writer kept faithfully logging \"shots\" out\nof a point already finished. A bug had secretly been doing somebody\nelse's job.</p>\n<p>The first fix I tried didn't work: a ball still in play after a real\nhit moves fast, a dead ball bouncing after a point should be slow, and\nthat gap looked clean — until the day match's real serves turned out\njust as fast as the fake bounces, and the line I'd drawn cut through\nreal shots too.</p>\n<p>What worked was tennis, not more tuning: a ball still part of the point\nnever leaves the court's playable area — the same screen-to-court\nconversion, the <a class=\"\" href=\"https://optic-yellow.com/primer#homography\">homography</a>, used elsewhere in\nthis project. Every phantom shot sat outside that area; every real\nshot, across both matches, stayed inside it, serves excepted (a legal\ntoss goes up and out on purpose). One rule, one exception, and the\nphantom shots disappeared. Checked again against the human chart, the\nnight match's shot-count came back to ten of eleven — the best this\nproject has scored — while the day match's numbers didn't move by a\nsingle shot: proof the fix cleaned up the one problem it targeted and\ntouched nothing else.</p>\n<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>\n<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>\n<p>Good, here, is the specialist matching or beating the generalist\neverywhere that counts, at zero cost — which is what happened, except\non a handful of easy, brightly lit clips where the generalist still\nedged ahead by a few points.</p>\n<p>Sometimes the scariest bugs in a pipeline aren't the ones that break\nsomething — they're the ones that had quietly been holding something\nelse together.</p>\n<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\nverified, 25 survived. Specialist = WASB-SBDT (BMVC 2023, NTT, MIT\nlicense), pretrained tennis weights, 6 MB, F1 94–96 on the\nbroadcast-tennis benchmark. TennisProject replicates our classical\nstack (ball, court keypoints, homography, players, bounces). No\nproduct/paper/repo found ingests broadcast video and emits a symbolic\nshot-by-shot chart: SwingVision wants your own phone footage, TennisViz\nconsumes Hawk-Eye's tracking feed, Hawk-Eye runs ~EUR 60–70k/court/week.\nMatch Charting Project: ~17,800 matches, ~a quarter of tour matches,\nSackmann's January post pleads for more charters.</p><p>A/B, project shorthand: night match = t1, day match = t2. ~200-line\ndriver (hard-asserts CUDA; ours runs on MPS, 15–20 fps), frozen chart\nloop untouched. SAM's disaster clips evaporate under the swap\n(55%→93%, 55%→87%); its only wins are 3–7 points on easy floodlit\nclips.</p><p>Regression: t1 rally length 7/11 → 5/11, all over-counts. Failed fix:\nvelocity magnitude — coda bounces swing 109–210 px/frame vs real hits'\n8–33, a beautiful gap until t2's serves landed inside it. Fix that\nheld: one positional gate, serve-window exemption, three new\nconstants, frozen-era constants untouched — t1 lands at 10/11, t2\nbyte-identical before/after. All 60 dev-reel clips retracked at $0,\nincluding 37 SAM never touched.</p></div></div></details>\n<p>Session cost: $0.00. Project total: ~$4.15 of $9 — and nothing ahead of\nus charges by the frame anymore.</p>",
            "url": "https://optic-yellow.com/blog/the-zero-dollar-specialist",
            "title": "The $0 Specialist and the Load-Bearing Bug",
            "summary": "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.",
            "date_modified": "2026-07-10T01:15:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        },
        {
            "id": "https://optic-yellow.com/blog/t1-right-hardware-wrong-sign",
            "content_html": "<p><em>That clip up top is match point of Shapovalov's win over Nadal,\nMontréal 2017 — the point that made his career. Our software's chart\nassembles on the top line; a human volunteer's runs below it. The\nhuman's line ends <code>f+1*</code> — forehand (f), closing in on the net (+), to\na corner (1), a clean winner (</em>). The software's ends with Shapovalov\nflat on his back, most labels still <code>?</code>, its symbol for \"couldn't\ntell.\" That gap is what this post closes.*</p>\n<p>Could software, rules locked exactly as last tuned, watch a match it\nhad never seen and write a chart a tennis fan would call the same\nmatch?</p>\n<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>\n<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\nshot</a> by weighing three signals — did\nthe ball reach that side, the serve call, where the shot landed. Last\npost, <a class=\"\" href=\"https://optic-yellow.com/blog/t1-answers-in-the-back\">a human volunteer's chart of a real\nmatch</a> — Nadal against Shapovalov, at\nnight, in Montréal — became the project's first answer key, ending a\nstretch of calling its own lines: fine for practice, worthless as\nproof. (Why that matters: <a class=\"\" href=\"https://optic-yellow.com/primer#ground-truth\">Court Vision in Plain\nEnglish</a>.) Today's rule, set before a single\nclip played: nothing changes before the scorecard.</p>\n<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>\n<p>Every tennis fan carries this rule without thinking: players swap ends\nafter the first game, then after every odd-numbered game total, and\nevery six points in a tiebreak. Tell a fan the score is four games to\nthree and they'll say, without looking, which end each player stands\nat — arithmetic, not a guess. The software had to learn that same\narithmetic: no coin toss to watch, just a ball arriving from the near\nor far side of the screen, so it computes which end a player stands at\nfrom the score alone.</p>\n<p>That arithmetic matters beyond serve calls. When software is wrong in\na way that flips on one fact it never checked, the fault usually isn't\nwhat it's measuring — it's a label it assumed instead of computed.\nCall this the <strong>mirrored error</strong>: every miss points the same\ndirection, so the tool's eyes work fine and only a naming convention\nflipped — a line judge with perfect eyesight calling \"out\" every time\nshe means \"in.\" You wouldn't trust her calls, but you wouldn't send\nher for glasses.</p>\n<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>\n<p>Stabilizing the footage, building the empty-court reference photo (the\n<a class=\"\" href=\"https://optic-yellow.com/primer#background-subtraction\">median-frame trick</a> that erases\nplayers and ball so anything left over stands out), and tracking\nplayers ran on the Montréal footage unchanged. Far-side coverage\nimproved: 100% here versus 96% on the practice reel, because\nfloodlights kill the shadows that had tricked the daytime footage.</p>\n<p>A new problem showed up — the fourth time something that isn't a\nplayer has fooled the player-finder. This court's ball kids crouch at\nthe net posts, and the code assuming \"the biggest moving blob is the\nplayer\" grabbed them, opening serve-toss windows above a ball kid's\nhead, once above a chair umpire's. The fix is geometry: a server stands at\nthe baseline, so the bootstrap checks each candidate's foot position\nin real court coordinates — the <a class=\"\" href=\"https://optic-yellow.com/primer#homography\">screen-to-court\nconversion</a> the project runs on — and rejects\nanyone standing where a server never would.</p>\n<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>\n<p>The serve detector called a direction confidently on 11 of 25 clips\nhere, down from 40 of 60 on the practice match — a harder test: a\ndifferent match, at night.</p>\n<p>The human chart already knows who served every point; changeover\nparity turns that into \"which end that server stood at,\" once you\nknow where the match started. Run it both ways: \"Nadal started far\"\nexplains 11 of 14 confident calls, \"near\" explains only 3. Nadal\nstarted far — a real accuracy figure of 11 of 14, about 79%, up from 6\nof 8 on the practice match, checked by eye instead of a chart.</p>\n<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>\n<p>Ball tracking still runs behind a spending gate: a human eyeballs a\nproposed box before the project pays to send it to the <a class=\"\" href=\"https://optic-yellow.com/primer#wasb-vs-sam\">paid,\nprompt-it-yourself tracker</a>. Of 25 proposed\nboxes, 13 were approved — nine on the first look, three more after the\nball-kid fix, one via a backup rule that just follows whatever's\nmoving fastest. Twelve were sent, $0.85 total, eleven usable; the\ntwelfth, a shaky approval, died twelve frames in. That check is 6 for\n6 across both matches at picking winners. It stays.</p>\n<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>\n<p>Eleven points, chart locked, graded against the human's:</p>\n<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>\n<p>That last pair is the headline — the best bad result this project has\nproduced. Nadal and Shapovalov are both left-handed; the software\nstill assumes every player is right-handed, a risk flagged and frozen\non purpose so a lefty-heavy match would expose it. A lefty's forehand\nlands where a righty's backhand does, and vice versa — assume the\nwrong hand and the label flips, though the geometry stays right. Nine\nmirrored calls out of eleven means the software found the right side\nof the body at contact almost every time — just the wrong name for it.\nOne line of match configuration — telling it who's left-handed — turns\ntoday's 2 of 11 into roughly 9 of 11 at the next freeze: the cheapest\nseven-point improvement this project will ever buy.</p>\n<p>The rest of the misses hold up too. A tie-break rule letting a strong\nserve call override a shaky forehand/backhand vote had rescued one\nconfusing point on the practice match; here it pushed two calls wrong\ninstead. And rally length runs long because some points include a\nfault — a missed first serve — before the point that counts; the human\nchart logs both separately, but the software counted the fault as part\nof the rally. Starting the count from the first confident serve fixes\nboth.</p>\n<p><strong>When every miss points the same direction, you haven't found a\nbroken tool — you've found a fact about the match it never bothered to\ncheck.</strong></p>\n<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>\n<li class=\"\">The tie-break rule that wronged two calls here fixed point_59 on the\ndev reel: thin far-end tracks make touch votes lie harder than serve\npriors, so its vote weights are a flagged dev-reel artifact.</li>\n<li class=\"\">Handedness was flagged as a known liability in the who-hit-it\nmilestone (M3) and frozen on purpose for this test.</li>\n<li class=\"\">The ballkid impostor triggered the largest-connected-component\nplayer pick; the paid tracker throughout is SAM 3.</li>\n</ul></div></div></details>\n<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>\n<p>An automated system charted eleven points of a tennis match from\nbroadcast video and got graded against a human's chart of those\npoints — the actual thing this project set out to test, now with a\nnumber instead of an impression. The number is bad in the most useful\nway a number can be: the biggest error has a direction, a known cause,\nand a one-line fix.</p>\n<p>Test-set discipline held — nothing tuned mid-grade. Session cost\n$0.85; project total about $3.65 of the $9 budget. Next: handedness\ngoes into the match configuration, then a control match — Federer\nagainst Haase, two right-handers — answers whether this was ever\nreally about left-handedness at all.</p>",
            "url": "https://optic-yellow.com/blog/t1-right-hardware-wrong-sign",
            "title": "T1: Right Hardware, Wrong Sign",
            "summary": "Graded against a real human chart for the first time, the system's biggest miss was a lefty problem, not a broken tool.",
            "date_modified": "2026-07-10T01:00:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        },
        {
            "id": "https://optic-yellow.com/blog/t1-answers-in-the-back",
            "content_html": "<p><em>That clip up top: two court outlines on the same night-match frame. Red is\nthe camera math borrowed as-is from the practice match; green is a fresh\nfit built from this match's own lines. Red drifts visibly off the real\ncourt — and its own accuracy numbers still said it looked fine.</em></p>\n<p>Every setting this project has tuned so far was checked by the one person\nwho tuned it. That's a chair umpire overruling his own call and then\ncongratulating himself for getting it right — the call might be correct,\nbut nobody should trust it just because he says so.</p>\n<p>Previously: the plan is to feed broadcast video in and get the same\nshot-by-shot notation out that Match Charting Project volunteers\nhand-code by watching real matches (<a class=\"\" href=\"https://optic-yellow.com/blog/auto-charting-kickoff\">cv-01</a>).\nEvery dial since then — how confidently motion counts as a swing, how the\nserve toss gets spotted, whose shot each ball is credited to — was tuned\non one practice match, Zverev–Gasquet, and checked with my own eyes\n(<a class=\"\" href=\"https://optic-yellow.com/blog/m3-the-ball-gets-a-vote\">cv-09</a>). So was the\n<a class=\"\" href=\"https://optic-yellow.com/primer#homography\">homography</a> — the rule, built from the court's\npainted lines, that converts a screen position into a court position\n(<a class=\"\" href=\"https://optic-yellow.com/blog/m1-pixels-to-meters\">cv-03</a>).</p>\n<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>\n<p>A line judge who grades his own overrules isn't cheating, exactly — he's\njust human, and human eyes trust their own first read. Tennis's fix is a\nsecond set of eyes that never made the call. This project needs the same\nfix: an answer key built by someone who never saw its output. This\ndevlog calls that <strong>ground truth</strong> (more: <a class=\"\" href=\"https://optic-yellow.com/primer#ground-truth\">Court Vision in Plain\nEnglish</a>) — and Jeff Sackmann's Match Charting\nProject supplies it: volunteers who hand-code real matches, shot by\nshot, in the exact notation this project is trying to produce.</p>\n<p>There's a second half to the rule, the half that makes a test count\ninstead of a rehearsal: the moment a match gets used to fix a bug, it\nbecomes practice, not proof. So every setting gets locked first —\nwritten down, untouched — before the software meets a match it never\ninfluenced. Freeze first, grade second: that's a <strong>benchmark</strong>.</p>\n<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>\n<p>The Match Charting Project has hand-charted more than 7,500 men's\nmatches; the practice match isn't one of them. A match from the same\ntournament, court, and broadcast package is: Canada Masters 2017, Nadal\nvs. Shapovalov, round of 16 — 225 points, charted point by point, at\nnight under floodlights, both players left-handed. Neither fact was\ntested on the practice match: its shot calls assume a right-hander, its\nmotion settings were tuned in daylight. Same court and graphics package,\nso any failure here traces to what actually changed.</p>\n<p>Held in reserve: Federer vs. Haase, the semifinal — day, both\nright-handed, the closest match here to practice conditions. If the\nsoftware struggles there too, lefties and floodlights aren't why.</p>\n<p>The test's rules were locked before any of it ran: no setting changes\nonce grading starts (one part stayed adjustable — appendix). For every\nclip that lines up with a human record, the check compares who served,\nwhere the serve landed, how long the rally ran, and how often the\nshot-by-shot calls match — counting only what the software committed to,\nnot its shrugs. That 225-point answer key is public, free with\nattribution, and cost nothing to get.</p>\n<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>\n<p>Each clip first has to be found, then matched to a point. Finding them\ngave the practice match the most trouble; here it barely put up a\nfight — point the software at the file paths and the new camera math,\nand 25 clips of real play fell out, no setting touched. Matching them\nmeans reading a score bug — the little scoreboard graphic in the corner,\nfrozen pixel-for-pixel during a point, changing only when the point ends\n(more: <a class=\"\" href=\"https://optic-yellow.com/primer#score-bug\">Court Vision in Plain English</a>) — and finding\nthe line in the human's chart with that same score.</p>\n<p>One convention nearly wrecked it: the human record lists the <em>server's</em>\nscore first; this match's bug always lists Nadal's first, serving or\nnot. Every Shapovalov service game disagreed on which number came\nfirst, costing six wrongly-matched points before I caught the pattern.\nBefore the fix, 17 of 25 clips lined up; after it, 23 of 25 did.</p>\n<p>The remaining two aren't errors, they're genuine ties: a 40–Advantage\nscore that happened twice in the same game, and a deuce from a game that\nreached deuce ten times — indistinguishable by score alone. The process\neven caught a mistake of mine: I'd misread one clip's serve indicator by\neye, and the human chart showed Shapovalov had actually served.</p>\n<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>\n<p>The camera math built on the practice match missed by about 20 pixels on\nthis new footage — different filming day, camera remounted. Refitting it\ntook three failed attempts before a fourth worked.</p>\n<p>Attempt one reused the first homography experiment's approach,\nunchanged. Under floodlights, the paint boundary where the court's blue\nsurface meets its green surround reflects light almost exactly like a\nreal white line. The fit grabbed that paint edge and drew a court 15 to\n20 pixels too big.</p>\n<p>Attempt two made the software pickier about what counts as a white\nline — which killed the paint problem, and the far baseline and both\nsidelines along with it. Not enough real lines left to build a court\nfrom.</p>\n<p>Attempt three searched narrow bands near where the practice match's\nlines had been. It failed more quietly: one band locked onto ground\npaint 70 pixels off; another mixed in doubles pixels while hunting for a\nsingles line. The fit then reported a great accuracy number for\nitself — but that number only measures agreement with the pixels the fit\nchose to trust, and some of those were the wrong paint. Drawn onto the\nactual frame and eyeballed, the center line was obviously off, by 32\npixels: ten times what the fit had reported.</p>\n<p>What worked: a human — me — zoomed the frame in six times and picked the\ncourt's four corners by eye, once. The same kind of one-time nudge the\nvery first tracking experiment needed, when someone had to draw a box\naround the ball before it would track\n(<a class=\"\" href=\"https://optic-yellow.com/blog/m0-first-tracked-rally\">cv-02</a>). Even that failed twice at\nfirst: the corners read near the baseline were the <em>singles</em> corners,\nnot the doubles corners the court is actually measured to, further out\nand blurred into paint at the frame's edge. Two pieces of hand-worked\ngeometry confirmed which was which. The final fit mixed far doubles\ncorners with near singles corners, holding attempt two's lines back as\nan independent check.</p>\n<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>\n<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>\n<p>This is the third time this project has hit the same wall, so it's a\nstanding rule now: a number can vouch for a wrong answer, but a drawn\nline on the actual picture can't lie the same way. Every camera-math fit\ngets rendered onto the video and eyeballed before it's trusted — which is\nexactly what the two colored outlines in the clip up top are for.</p>\n<p>Twenty-five clips found, twenty-three tied to a line from a human's\nchart, a court that measures accurately, every setting still frozen.\nCost for both sessions: $0.00 — public data and an afternoon staring at\npixels. Next: turn this into an actual scorecard, machine against human,\npoint by point.</p>\n<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\nrepo under <code>data/mcp/</code> (CC BY-NC-SA, attributed). Eval protocol, exact\nterms: constants frozen; segmentation could still be re-tuned for the new\nreel's cuts (finding points isn't the hypothesis — charting them is);\nmetrics per aligned point are server end, serve zone, rally length, and\nletter accuracy over committed letters only; coverage reported beside\naccuracy; no silent cherry-picking.</p><p>Score-bug join: MCP's <code>Pts</code> column is server-first, the on-screen bug is\nNadal-first. <code>point_06</code>'s serve marker was misread by eye; the join\ncorrected it.</p><p>Homography attempt 1 was M1's recipe, verbatim; under floodlights the\nblue-to-green paint boundary reads V≈155, S≈55, the same signature as a\nreal line at grazing angle. Attempt 3's guided bands were seeded from\nthe dev prior; its self-reported residual was a beautiful 2.9 px,\nagainst the 32 px actually on the ground — plausible-but-wrong, caught\nonly by rendering the reprojection and looking at it.</p><p>The fix echoes the M0 ball-click precedent: one human input per match.\nThe two cross-checks that settled which corners were real — far-corner\nmidpoint lands at x=603 against a measured center line of 606, near-corner\nreads implied 643; pixels-per-meter is linear in image y (48 at the far\nbaseline through 80 at the near service line), extrapolating to ~100 at\nthe near baseline and predicting a doubles span of ~1095 pixels, exactly\nthe white band's raw extent. The final fit held attempt 2's tight-mask\nlines back as untouched validators rather than fitting to them.</p></div></div></details>",
            "url": "https://optic-yellow.com/blog/t1-answers-in-the-back",
            "title": "T1: A Test Set With Answers in the Back",
            "summary": "The software faces its first honest test against a match a human already scored, and the camera math needs fixing first.",
            "date_modified": "2026-07-10T00:45:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        },
        {
            "id": "https://optic-yellow.com/blog/m3-the-ball-gets-a-vote",
            "content_html": "<p><em>That clip up top: a single point charting itself live. A box tracks the\nball, a second box flashes onto whichever player the software thinks\njust struck it, and a line of notation builds along the bottom, shot by\nshot. Late in the clip, watch that second box drift off the court and\nland on a spectator — hang onto that, it matters later.</em></p>\n<p>Freeze a rally at the instant of contact, and sometimes you genuinely\ncan't tell who just hit the ball — it's caught in the air, equally far\nfrom both players.</p>\n<p>This project is teaching software to watch broadcast tennis and write\nout the same shot-by-shot notation a human volunteer hand-writes for the\nMatch 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\nentry</a> solved the pipeline's final\nmanual step — the serve toss rises alone above the server's head, so the\nsoftware now finds and boxes the game ball itself — and produced the\nfirst complete points, start to finish. But the column recording who\nstruck each shot broke tennis's own rule that hitters alternate,\nwhenever the ball was caught mid-air at contact, equally far from both\nplayers.</p>\n<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>\n<p>Tennis already has a model for resolving exactly this kind of\ndisagreement: a chair umpire weighing a disputed call. No single witness\ngets trusted blindly — an electronic call, a line judge's shout, the\nmark on the court can each say something different — and the umpire\ndecides which to believe, not just which spoke first.</p>\n<p>That's the idea that replaced \"whoever's closest.\" It leans on the\nsport's strongest fact: hitters alternate. A rally has exactly two\npossible stories — near player struck first, or far player did — so\ninstead of deciding shot by shot, the software decides once per rally,\nscoring both stories against independent evidence and keeping whichever\none wins. That's a <strong>weighted vote</strong>: several independent signals, each\ntrusted by a different amount, added together into one decision.</p>\n<p>The alternating pattern itself is allowed to bend in exactly one place:\nif the ball vanishes from the tracker for a stretch of frames, a hit\ncould be hiding inside that gap, undetected — and only there can the\nnear-far-near-far pattern skip a beat.</p>\n<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>\n<p>The first fix tried was the obvious one: whoever's closest to the ball\nat contact hit it. It failed immediately — right at the instant a hit\nis detected, the ball is often out in open court, closer to neither\nplayer than the other.</p>\n<p>The next idea reached for physics instead of position: after a real\nhit, the ball should move away from whoever struck it. Converted into\ncourt coordinates, that direction came back backwards after hits from\n<em>both</em> ends. The culprit is the <strong>homography</strong> — the recipe that turns\na spot on the TV screen into a spot on the court diagram, more on it in\n<a class=\"\" href=\"https://optic-yellow.com/primer#homography\">Court Vision in Plain English</a> — which only tells\nthe truth for things touching the ground. It's been lying about anything\nairborne since the <a class=\"\" href=\"https://optic-yellow.com/blog/m1-pixels-to-meters\">very first post that built\nit</a>; bounces happen on the ground, so they\nwere always honest, and nobody had noticed the lie until this rule\nleaned on it.</p>\n<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>\n<p>Three witnesses get called per rally, in order of trust. <strong>Touch</strong>\ncounts most: did the ball reach exactly one player's own patch of court\nduring the contact window? <strong>The serve call</strong> counts less — a starting\nhunch, not gospel. <strong>Which half the ball landed in</strong> counts least: a\nbounce detector — the \"speed suddenly collapses\" signature from <a class=\"\" href=\"https://optic-yellow.com/primer#hits-and-bounces\">Court\nVision in Plain English</a> — only watches the\nfar half of the court right now, so it can vote \"the near player hit\nit\" but not the other way round yet. Half a witness, but an honest one.\nEvery vote that loses stays on the record instead of getting erased — a\nbuilt-in honesty check, so a witness wrong more often than it should be\ncan't quietly hide its bad calls.</p>\n<p>Making the serve call outvotable paid off the first time it mattered.\nIn one point, the serve detector was confident the point started at the\nnear end; touch and landing evidence together pointed far; the vote\nwon. A frame-by-frame check of the footage sided with the vote.</p>\n<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>\n<p>Once a rally's pattern is settled, proximity becomes trustworthy again\nfor a narrower job: pinning down the exact contact frame, now that it's\nknown which player to look near. But the stroke-type letter — forehand\nor backhand — only gets written down if the ball demonstrably reached\nthat player's own patch of court, not just \"somewhere nearby.\" That\npatch scales with how big the player looks on screen: the far player is\nonly about 55 pixels tall, so one fixed-size box for both would reject\nnearly every far-end letter before it had a chance.</p>\n<p>Tracking boxes also go rogue at the worst moments — one wandered onto a\nspectator standing by a Perrier cooler, another landed on a court shadow\ninstead of a person. That's the box that drifted off-court in the clip\nup top. Whenever that happens, the letter stays a question mark — the\nsame \"declined to guess\" mark explained in <a class=\"\" href=\"https://optic-yellow.com/primer#notation\">the notation\nlegend</a> — rather than a wrong one.</p>\n<p>Read with that legend, the clip's string is: a serve down the T (<code>s6</code>);\na forehand toward one side (<code>f1</code>); a forehand where the stroke is\ncertain but the side isn't (<code>f?</code>); a backhand toward the other side\n(<code>b3</code>); a backhand with the side unresolved (<code>b?</code>); a shot nobody's sure\nwho hit, though the ball's own path still says the other side (<code>?3</code>); a\nforehand back up the middle (<code>f2</code>); and an ending not written yet, so\nit's <code>?</code> too. Spot-checked against video, the committed letters held up.</p>\n<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>\n<p>Before any paid tracking ran, the plan got a free review: every\ncandidate starting box went into one grid of still images, eyeballed by\nhand. That alone killed most of the backup ball-finder's picks — the\nmethod that kicks in with no confident serve toss, chasing \"the fastest\nsmall thing moving,\" fooled by lens glare, shadow edges, court-line\njunctions, the net band, a FedEx cooler, even a ballkid. Of 29 backup\ncandidates reviewed, only 3 survived. Three points moving in a straight\nline isn't proof of a flying ball, especially mid-camera-pan — a\nbackground object can fake that chain too.</p>\n<p>The same pass fixed the main method for free. The search box for the\ntoss above a server's head used to be a fixed size, so on far-end serves\nit was searching well above a player barely 55 pixels tall — effectively\nsearching the crowd. Scaling that box to the player's own size, plus a\nfilter dropping racket-shaped blobs, recovered seven far-end tosses the\nold search had thrown into the stands.</p>\n<p>23 clips went to the paid tracker; 18 came back usable. Five were lost\noutright — SAM, the promptable tracker from <a class=\"\" href=\"https://optic-yellow.com/primer#wasb-vs-sam\">Court Vision in Plain\nEnglish</a>, locking onto a stuck box instead of the\nball, including every borderline case that had looked \"good enough\" on\nthe free review. The eyeball review, not any automatic score, was the\nreal ground truth.</p>\n<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>\n<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>\n<p>Both serve codes finally show up correctly on far-end serves — six of\nthem — s5 (to the body) and s6 (down the T), the <a class=\"\" href=\"https://optic-yellow.com/primer#notation\">zone\ncodes</a> that had existed in the code for a while but\nwere never switched on; serves had been getting a cruder guess instead,\nthe same rough \"which third of the court\" scale every other shot uses.\nOne string came back fully committed: <code>s5f3b3?</code> — a body serve, a\nforehand toward one side, a backhand toward the same side, and the same\nunwritten ending every string gets right now.</p>\n<p>Put it the way a tennis fan would: don't trust whoever's standing\nclosest, trust the best evidence — and let it overrule your own serve\ncall if it has to.</p>\n<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>\n<li class=\"\">Striker-assignment history: v1 (nearest player at the event frame)\ndied on the cusp problem. v2 (post-hit court-frame ball velocity) died\non the homography's ground-plane assumption — airborne \"court\nvelocity\" is dominated by vertical image-space motion and reads\nnegative after hits from both ends; true since M1, invisible until v2\nleaned on it. v3 (current): two candidate assignments per rally\n(near-first / far-first) plus one permitted parity flip at a\nball-track hole — confirmed on SAM losing the ball for frames 54–78 of\npoint_53, exactly where a far-end hit had gone missing; the flip is\nframe-verified.</li>\n<li class=\"\">Vote weights: touch = 3, serve call = 2, landing half = 1 (the\ncollapse/bounce detector, far-half-only by construction — one-sided,\nhonest). point_59: SERVER-OVERRIDE — the gated serve detector said\nnear, touch + landing said far, frame check confirms far was right.</li>\n<li class=\"\">Letter committal: refines to nearest approach to the <em>assigned</em>\nstriker. f/b commits only past a height-scaled box gate (a fixed\npixel gate previously rejected every far-end letter, since the far\nplayer is ~55px tall). <code>?</code> cases: a \"far\" box on a spectator by the\nPerrier cooler, a \"near\" box on a court shadow. Frame-verified\ncommits: 3/3 on point_16, plus a point_24 spot-check.</li>\n<li class=\"\">Dry-run montage: all 54 prompt boxes reviewed pre-spend. Killed 25/29\nmover-fallback bootstraps (glare, shadow edges, line junctions, net\nband, a FedEx cooler, a ballkid). Toss-bootstrap fixes: search window\nnow scales with apparent height (was a fixed 130px above a 55px far\nserver); ball-shape filter drops elongated racquet-arc blobs.\nRecovered 7 far-end tosses.</li>\n<li class=\"\">API run: 23 clips sent, 18 tracks returned, 5 lost outright (giant\nstuck boxes, every \"weak accept\"). Two lessons on the record: an\nall-dropped result used to crash the batch (now a graceful skip); fal\nchunks long videos — past ~490 frames it throws \"No prompts available\nfor this video chunk.\" point_55 worked trimmed to 450 frames;\npoint_58 (893 frames, the 49-shot point) is parked until\nsplit-and-stitch exists.</li>\n<li class=\"\"><code>serve_zone()</code> is actually <em>called</em> now — v1 had defined the function\nand then coded serves by full-court thirds anyway. Conflicts column\n(votes that lost) kept on record as the honesty metric.</li>\n</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>\n<p>Session cost: about $1.50. Project total: about $2.80 of the $9 budget —\nthis low because the free eyeball review caught problems before they\nbecame paid API calls. The toss bootstrap carried this session; the\nbackup ball-finder still needs a rethink, not a tune-up.</p>",
            "url": "https://optic-yellow.com/blog/m3-the-ball-gets-a-vote",
            "title": "M3: The Ball Gets a Vote",
            "summary": "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.",
            "date_modified": "2026-07-10T00:30:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        },
        {
            "id": "https://optic-yellow.com/blog/m3-toss-is-the-bootstrap",
            "content_html": "<p><em>That clip up top: a whole point, serve included, charting itself — the\ntracking box locks onto the ball at the toss on its own, no clicking,\nand the notation string builds along the bottom. First time this\npipeline has gone start to finish, and it's still a little rough.</em></p>\n<p>Every rally tracked so far needed a person to draw a box around the ball\nby hand first. Fine once. Absurd sixty times. The fix: a fact about\ntennis itself. Every point begins the same way, and the software can now\nspot that beginning on its own.</p>\n<p>Two posts back, the first experiment taught a promptable tracking AI\ncalled <a class=\"\" href=\"https://optic-yellow.com/primer#wasb-vs-sam\">SAM 3</a> to hold a tennis ball through a\nrally — but only after a human circled that exact ball with a box, by\nhand: <a class=\"\" href=\"https://optic-yellow.com/blog/m0-first-tracked-rally\">it could follow once pointed at</a>,\nnever find one alone. <a class=\"\" href=\"https://optic-yellow.com/blog/m3-static-camera-isnt\">Last time out ended on a\ndebt</a>: its notation string was a rally's\nmiddle, no beginning, because nothing yet knew where a point starts.</p>\n<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>\n<p>No rally begins in the middle. Every point opens with one unmistakable\naction: a player stands still, tosses one ball straight up, and swings.\nNothing else moves like that — the receiver stands still, every spare\nball sits still in a ball kid's hands, and the one thing rising alone is\nthe game ball.</p>\n<p><strong>The bootstrap:</strong> instead of a person pointing at the ball every clip,\nthe pipeline uses something tennis always does anyway — the toss — to\npoint at the ball for itself. It already compares each frame to a photo\nof the empty court and flags what's different, the same <a class=\"\" href=\"https://optic-yellow.com/primer#background-subtraction\">trick that\nfinds the players for free</a>. The only\nnew blob near a still server, right before contact, is the rising toss —\nthe software boxes it itself (<a class=\"\" href=\"https://optic-yellow.com/primer#detection-vs-tracking\">finding, not yet\nfollowing</a>), then hands the box to the\ntracking software. Detect once, then track. The click is gone.</p>\n<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>\n<p>The pipeline has been fooled before, by ball kids holding spare balls\nand a line judge who erased himself standing still. This time it was\nbroadcast graphics. Without a confident serve, the software falls back\nto chasing the fastest small mover on court — and its first two guesses\nwere the scoreboard graphic (<a class=\"\" href=\"https://optic-yellow.com/primer#score-bug\">more on that box\nlater</a>) flipping on every score change, and a small\nnet sign vibrating in the breeze.</p>\n<p>The fix: a ball in flight keeps one direction and one rough speed, frame\nafter frame — flying <em>ballistically</em>. A flickering graphic doesn't fly\nanywhere, so requiring three straight consistent frames knocked both\nout. Two clips still beat it, fooled by lens glare off the court's own\nlines during a camera pan — unsolved, noted rather than hidden.</p>\n<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>\n<p>Six clips ran through the paid tracking service for 55 cents total,\nless than a candy bar. Those tracks met the <a class=\"\" href=\"https://optic-yellow.com/primer#hits-and-bounces\">hit-and-bounce\nreader</a> on a <em>complete</em> point for the first\ntime, and found something no mid-rally clip could show: <strong>servers\ndribble before the toss.</strong> Bouncing to yourself is a real hit and a real\nbounce, over and over, so the detector — correctly, by its own rules —\ncharted the warm-up as rally. Fix: the software already knows which\nframe the point starts on. Discard anything before it.</p>\n<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>\n<p>Two clips produced a full serve-to-final-shot line with nobody typing\nmid-point. One reads, shot by shot: serve toward the middle (<code>s2</code>), a\nbackhand the software couldn't place (<code>b?</code>), backhand wide (<code>b1</code>),\nforehand same side (<code>f1</code>), forehand back to the middle (<code>f2</code>), backhand\nwide the other way (<code>b3</code>), two more backhands, and an ending it couldn't\ncall — eight shots, serve confirmed right. The other was two shots and\nout: an unclear serve, one unplaced forehand return, an ending it\ncouldn't call.</p>\n<p>Checking both by eye: serve call right, shot count rally-shaped — but\nthe column saying <em>who</em> hit each shot broke the serve-return pattern\nwhenever the guess for contact caught the ball mid-air, equally far from\nboth players. Nearest-player-to-the-ball means nothing once the ball is\nclosest to nobody. The letters aren't trustworthy yet; the shape under\nthem is.</p>\n<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>\n<p>Say it the way a tennis fan would: you don't need to point at the ball\nif you already know every point starts with a toss.</p>\n<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>\n<li class=\"\"><strong>Click, closed.</strong> M0's flagged manual box-prompt: closed. Serve toss\n→ auto box prompt for SAM 3, from the frame-minus-plate difference\nimage at the serve detector's known toss location.</li>\n<li class=\"\"><strong>Ballistic filter, exact.</strong> 3-point chain: consistent direction,\nconsistent per-frame step. Line-junction glare during camera pans\nstill passes it on 2 clips — unsolved, parked.</li>\n<li class=\"\"><strong>Pilot run.</strong> 6 clips through SAM 3, $0.55 total. First attempt hit a\nrequest validation error — free lesson that the API wants integer\npixel coordinates, not floats.</li>\n<li class=\"\"><strong>Detector.</strong> M2's v4, fps now a parameter instead of baked-in 30,\nfirst run over full point clips (previously mid-rally excerpts only).</li>\n<li class=\"\"><strong>Two charted points, raw:</strong></li>\n</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>\n<li class=\"\"><strong>Deferred, on purpose.</strong> 48 of 54 clips not run this session — known\nfixes (alternation-aware striker assignment, contact-frame refinement,\nsparse-track handling) are free to iterate on the six already paid\nfor. Running cost: pilot $0.55, project total ~$1.30 of $9.</li>\n</ul></div></div></details>\n<p>This session's six clips cost 55 cents; project total is about $1.30 of\na $9 budget. 48 clips stay untouched on purpose — every fix still ahead\nis free to try on the six already paid for.</p>\n<p>The point string that opened this stretch read <code>??2?1?1?2?2?2???</code>,\nalmost all question marks, no <code>s</code> anywhere: a rally with a middle and no\nbeginning. It now starts with an <code>s</code>.</p>",
            "url": "https://optic-yellow.com/blog/m3-toss-is-the-bootstrap",
            "title": "M3: The Toss Is the Bootstrap",
            "summary": "The serve toss lets the software find the ball itself, and it charts its first full point without a human drawing a single box.",
            "date_modified": "2026-07-10T00:15:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        },
        {
            "id": "https://optic-yellow.com/blog/m3-static-camera-isnt",
            "content_html": "<p><em>That clip up top: every serve the pipeline felt sure about, clip\nafter clip — which player served, and which side of the court, deuce\nor ad, flashed the instant the toss goes up.</em></p>\n<p>Sixty broadcast clips are sitting there ready to be charted, and every\none starts with the same problem: telling an actual tennis player\napart from everyone else standing near a court who isn't one.</p>\n<p><strong>Previously:</strong> <a class=\"\" href=\"https://optic-yellow.com/blog/m3-the-cutting-room\">the last post</a> sliced a\nhighlights reel into sixty complete-point clips using screen-to-court\nmath as a camera fingerprint. That math, the <strong>homography</strong> — matching\nthe court's painted lines to its real dimensions to turn screen\nposition into court position, from <a class=\"\" href=\"https://optic-yellow.com/blog/m1-pixels-to-meters\">Pixels to\nMeters</a> — carries a free trick: median-blend\nmany frames of the same court and the players, who never stand still\nfor long, vanish into a photo of an empty court. <a class=\"\" href=\"https://optic-yellow.com/blog/m3-who-hit-that\">Two posts\nlater</a>, that trick already found both players\nin one rally well enough to call every forehand and backhand. Doing it\nacross sixty new clips would otherwise mean paying <strong>SAM 3</strong> — the\npay-per-prompt \"find that object\" AI that tracked a ball for about\nsixty cents in <a class=\"\" href=\"https://optic-yellow.com/blog/m0-first-tracked-rally\">the second\npost</a> — roughly $0.15 a clip, $9 for the\nbatch, every rerun.</p>\n<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>\n<p>This session tried to cash in that free trick everywhere: build the\nempty-court photo per clip, then flag whatever pixel changed as a\nplayer. One catch — both photos need the same camera position. A\nbroadcast camera looks fixed, but any camera operator worth their job\nnudges the shot to keep the rally centered, the way your own eyes\nwould tracking the ball from courtside. A pixel-by-pixel comparison\nnotices every nudge: the baseline in one photo no longer sits on the\nbaseline in the other. It's like tracing a picture on an Etch A\nSketch, then shaking the toy before checking your work — nothing\nlines up.</p>\n<p>The fix: <strong>stabilization</strong>, nudging every frame back into line with\nthe others before comparing anything, so the same pixel always means\nthe same spot on the court. More on why the empty-court trick needs a\nstill camera: <a class=\"\" href=\"https://optic-yellow.com/primer#background-subtraction\">Court Vision in Plain\nEnglish</a>.</p>\n<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>\n<p>That math, first fit two posts back on a sixteen-second rally, found\nalmost no camera movement — so the code kept one screen-to-court\nconversion for the whole clip. A full point can run a minute or more,\nand these sixty clips run that long. Aligning every frame of a clip\nto its own first frame and measuring the gap told a different story:</p>\n<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>\n<p><em>The broadcast camera, measured, over one point: a sharp jump — as\nmuch as 32 pixels — mid-point, on top of slow drift throughout.</em></p>\n<p>The camera operator was following the play — invisible to a viewer,\nfatal to the comparison. It blurs the empty-court photo, and near the\nfar baseline, thirty-some pixels of drift is worth <em>meters</em> of\ncourt-position error. Every frame now gets stabilized first.</p>\n<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>\n<p>With frames stabilized, the near player — big, always moving — showed\nup in essentially 100% of frames; the far player, barely 35%. The\nempty-court photo had swallowed him.</p>\n<p>He's small, and barely moves between points, so a photo built from\none clip bakes in a faint standing copy of him — the same trick that\nerased <a class=\"\" href=\"https://optic-yellow.com/blog/m3-who-hit-that\">a stationary line judge</a> two posts\nago.</p>\n<p>The fix: build the photo from five clips instead of one — this clip\nplus its four nearest neighbors, aligned and blended by taking the\nmiddle value at every pixel again. Players stand somewhere different\nevery point, so five ghostly copies never land on the same pixel\ntwice, and the blend votes them all out. Far-player coverage: 35% to\n96%. Near player: still ~100%. Cost: nothing.</p>\n<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>\n<p>The first serve-detector run flagged several clips with the far\nplayer standing 13.5 meters from the court's center, on a court only\n10.97 meters wide. Nobody serves from outside their own court.</p>\n<p>Those weren't players. They were <strong>ballkids</strong>, crouched past the\ndoubles sidelines inside the patch of court scanned for the far\nplayer, shifting weight just enough to out-move a motionless server.\nThird case of mistaken identity: <a class=\"\" href=\"https://optic-yellow.com/blog/m0-first-tracked-rally\">the very first\nsession</a> swept spare-ball-holding\nballkids into \"the tennis ball,\" and <a class=\"\" href=\"https://optic-yellow.com/blog/m3-who-hit-that\">two posts\nago</a> a stationary line judge got swept into\n\"the empty court.\" To a pixel-by-pixel comparison, anyone standing\nnear a court looks like they belong on it. Fix: the far-side scan\nregion is now cut tight to the court; the near side stays generous.</p>\n<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>\n<p>With players tracked, the serve should be readable from posture: the\nserver hugs the center mark, the returner waits wide — which side of\nthe mark gives away deuce or ad, for free — and the toss stretches\nthe server's outline to full reach, so blob height should spike right\nat the toss.</p>\n<p>Version one checked only distance to the center mark, and failed\nfast: some clips open mid-rally with no toss to find, some end on a\nleftover zoom fragment, and — the costly lesson — returners often\nstand nearly as far from the mark as a server does, so a return swing\ncan read as a toss.</p>\n<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>\n<p><em>Right: a clean toss, full stretch, on the flagged frame.</em></p>\n<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>\n<p><em>Wrong: the flagged \"server\" is the returner, off the mark,\nmid-swing — every check in version one said yes.</em></p>\n<p>Version two clears several checks before it commits — tracked early\nand reliably, right distance from center mark and baseline, a genuine\nheight spike — or it refuses, with reasons attached, instead of\nguessing.</p>\n<p>Result: 40 of 60 clips get a confident serve call; the other 20 are\nrefused, on the record — clips that open mid-rally or rejoin\nmid-point, plus two long clips too thin on far-player tracking to\ntrust, including the marathon 49-shot rally from <a class=\"\" href=\"https://optic-yellow.com/blog/m3-the-cutting-room\">the last\npost</a>. Spot-checking a sample of the\nconfident calls by eye: 6 of 8 named the right server, 5 of 8 found a\nusable toss moment — honest, not perfect. Geometry alone tops out\nhere; a serve is, definitionally, the ball leaving the hand and\ncrossing the net, and once the ball itself is tracked over these\nclips, it settles what posture can only guess at.</p>\n<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>\n<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>\n<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>\n<p>A camera that looks perfectly still to a viewer at home can still\nwreck a pixel-by-pixel comparison — so before comparing two photos of\nthe court, make sure they were taken from exactly the same seat.</p>\n<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\nECC (Enhanced Correlation Coefficient) — an alignment method —\nrestricted to pure translation (x/y shift, no rotation/scale).\n<code>point_29</code> is the 32px-spike example, now the mandatory first step\nbefore plate-building, background subtraction, or homography\nprojection. The free trick cashed in this session: frame-minus-median\nalready IS a player detector, left over from M1's clean-plate trick.\nGhost-plate fix: a 5-clip window (target + 4 nearest temporal\nneighbors), each ECC-aligned before median-merging; zero added API\ncost, pure numpy.</p><p>Serve detector v1: single heuristic, distance-to-center-mark only.\n<code>point_33</code>: clean, correctly-flagged case. <code>point_40</code>: failure case,\nflagged \"server\" is the returner, mid-swing, off-hash, passing every\nv1 check anyway. v2: four required gates — (1) early-clip track\ncoverage, (2) distance to center mark, (3) distance to own baseline,\n(4) a genuine local peak in blob height. Any failed gate produces an\nexplicit <code>no_confident_serve</code> result with reasons attached, not a\nforced guess. Refusal log flags cold-opens, mid-point rejoins, and two\nlong clips with sparse early far-tracks — including the 49-shot rally\nfrom the cutting-room post (cv-06).</p><p>Also queued: fps as a real parameter — this reel runs 25fps against\nthe original clip's 30fps, and velocity thresholds are secretly\nper-frame numbers.</p></div></div></details>\n<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>\n<p>Session tally: player positions across all sixty clips, serve details\non two of three, three wrong assumptions caught — for $0.00. Project\ntotal: still about seventy-five cents, all spent in <a class=\"\" href=\"https://optic-yellow.com/blog/m0-first-tracked-rally\">the very first\ntracked-rally session</a>. SAM 3, the paid\ntracker, has exactly one job left: finding the ball.</p>\n<p>Next: a ball detector on these same sixty clips. Then the serve call,\nhits, bounces, and shot letters should click into a complete point,\nwritten in the Match Charting Project's own notation — the human-made\n<a class=\"\" href=\"https://optic-yellow.com/primer#ground-truth\">answer key</a> this project is chasing. The one\nworking chart string so far, <a class=\"\" href=\"https://optic-yellow.com/blog/m3-who-hit-that\">from two posts\nago</a>, was just the middle of a rally. The next\none should have a beginning, a middle, and an end.</p>",
            "url": "https://optic-yellow.com/blog/m3-static-camera-isnt",
            "title": "M3: The Static Camera Isn't",
            "summary": "The 'still' broadcast camera actually drifts mid-point — and that breaks a free trick for spotting players in video.",
            "date_modified": "2026-07-09T23:59:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        },
        {
            "id": "https://optic-yellow.com/blog/m3-the-cutting-room",
            "content_html": "<p><em>That clip up top: sixty point candidates, one after another, cut\nautomatically out of a 24-minute highlights reel — almost every one\nstarts on a serve.</em></p>\n<p>Watch a tennis broadcast and count how much of it is actually a point\nbeing played. Not much. There's a replay after nearly every big rally, a\ncoach's-box shot, a close-up between points — actual tennis is a small\nslice of the screen. Today's question: can software find, on its own,\nonly the real points inside a whole reel of broadcast footage? It can —\nand it turned up the best plot twist of the project so far.</p>\n<p>The project's goal, set on <a class=\"\" href=\"https://optic-yellow.com/blog/auto-charting-kickoff\">day one</a>, is\nsoftware that writes the same shot-by-shot notation <a href=\"https://tennisabstract.com/charting/meta.html\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">Match Charting\nProject</a> volunteers write\nby hand — that first post already flagged finding the right clips as its\nown job, then four posts put it off. <a class=\"\" href=\"https://optic-yellow.com/blog/m1-pixels-to-meters\">An earlier\nsession</a> built the conversion from screen\nposition to court position, matched to the court's own painted lines.\n<a class=\"\" href=\"https://optic-yellow.com/blog/m3-who-hit-that\">The most recent session</a> called every forehand\nand backhand across a whole rally — but that rally was one 16-second\nclip that starts and ends mid-point, with no serve, ending, or score\nanywhere in it.</p>\n<p>That clip's match — Zverev versus Gasquet, Montreal 2017 — also has a\n24-minute <em>extended</em> highlights reel: same broadcast, same camera,\ndozens of complete points, cut together with close-ups, crowd shots, and\nslow-motion replays. So today's job isn't a tennis question. It's an\nediting-room one: out of the reel's 36,234 individual pictures, which\nshow the real camera, and which are decoration?</p>\n<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>\n<p>A tennis broadcast has one main camera — high, behind the baseline,\nshowing the whole court — the only camera a Match Charting Project\nvolunteer could chart from. Everything else the director cuts to is a\ndifferent camera, and a highlights reel is built almost entirely out of\nthose cutaways.</p>\n<p>The first idea, tried and broken below, was to spot the difference by\ncolor: the court is blue, the ground around it is green. What actually\nworked reused something already built: an earlier session's conversion\nfrom screen position to court position, the <strong>homography</strong>, fit once by\nmatching the court's painted lines to its real dimensions (<a class=\"\" href=\"https://optic-yellow.com/primer#homography\">more on it\nhere</a>). That conversion doubles as a <strong>camera\nfingerprint</strong> — a test for whether a frame was shot from that one\ncalibrated camera position. Project the true shape of a tennis court\nthrough it onto a new frame: on the main camera, the lines land exactly\non the paint, because the conversion was built for that camera's\nposition. On any other camera they miss — the projected court floats\noff into empty space instead of matching anything real.</p>\n<p>Checking a frame becomes checking a few dozen spots: does court-colored\npaint sit where the projection says it should, and does\nthe strip just outside the doubles lines correctly <em>not</em> have court\npaint in it? Pass both, it's the main camera. Fail either, it's a\ncutaway — including an actual replay, which would otherwise chart the\nsame point twice.</p>\n<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>\n<p>The first classifier measured blue and green in each frame against a\nthreshold, tuned on frames from late in the match. It looked great —\nevery accepted frame really was the main camera.</p>\n<p>Then came the check nobody skips: looking at what it <em>rejected</em>. Six\nframes were obviously real points. All from the first set. All in\nblazing daylight.</p>\n<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>\n<p><em>The blue region found in a daytime frame: the court and the shaded\nseating bowl behind it, fused into one shape covering half the picture.</em></p>\n<p>Two things were going wrong, both about light. In daylight, the\nshaded seats read as the same blue as the court and merge into one giant\nshape. At the same time, the sunlit ground around the court washes out\ntoo pale to register as green. <strong>The match starts at noon in full sun\nand ends at night under floodlights, and a color rule tuned for one end\nbreaks at the other.</strong></p>\n<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>\n<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>\n<p><em>The projected court outline, laid onto two frames of the new reel —\ndaytime and evening. Pixel-perfect on both: same broadcast, same\ncamera.</em></p>\n<p>Fifty spots that had to be court-blue, seventeen just outside the\ndoubles lines that had to not be, checked frame by frame across the\nwhole reel. Smooth out single-frame flickers, keep only stretches of\nthree seconds or longer, and the reel breaks into <strong>60 segments, 725\nseconds of chartable tennis</strong> — about twelve minutes out of\ntwenty-four.</p>\n<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>\n<p><em>The first twenty segment openings. The service toss shows up again and\nagain — highlight editors cut in just before the serve, so it arrives\nfree with every cut.</em></p>\n<p>All sixty openings got checked by eye: every one is a real broadcast\nview, every one runs to a dead ball. Broadcast editors, it turns out,\nalready hand this project exactly the unit it needs: complete points.</p>\n<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>\n<p>Every long stretch between segments got checked too, turning up twenty\ngenuine cutaways — including sideline and net-camera <em>replays</em>, failing\nthe same way.</p>\n<p>One gap was a real loss.</p>\n<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>\n<p><em>Early in set one the broadcast used a tighter zoom. The court still\nread correctly inside, but the bigger court filling the frame swallowed\nthe outside-the-lines check. 131 seconds of real tennis, rejected.</em></p>\n<p>The \"camera never moves\" assumption broke for those two minutes — a\nknown hole, on the record. The fix, if needed: re-fit the conversion per\nstretch instead of once for the whole reel, which the earlier\nconversion work can already do automatically.</p>\n<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>\n<p><em>Twenty-four minutes of reel: 60 green segments found, one orange\nconfession.</em></p>\n<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>\n<p>All sixty candidate clips went to disk. Segment 51 stood out\nimmediately: <strong>59.6 seconds of unbroken main-camera footage, by far the\nlongest of the sixty.</strong></p>\n<p>That's the point this match is famous for — a 49-shot rally, the one\nthat shows up in YouTube video titles. The very first clip <a class=\"\" href=\"https://optic-yellow.com/blog/m0-first-tracked-rally\">this project\nwas built on</a> turns out to be a 16-second\nslice from the <em>middle</em> of that same rally. The segment\nfinder, which knows nothing about tennis and only checks colors and\nlines, found the entire point on its own — serve included.</p>\n<p><strong>Every session so far has been charting an excerpt of a much longer\nstory. It just got handed the whole thing.</strong></p>\n<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>\n<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>\n<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>\n<p>A tennis broadcast spends most of its time showing you everything except\nthe tennis, so before software can chart a single point, it first has to\nfind the one camera actually pointed at it.</p>\n<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\nhighlights reel, 36,234 frames.</p><p>v1: blue-fraction / green-fraction thresholds on HSV, tuned on\nlate-match frames. High precision, low recall — 6 rejected frames from\nset one (daylight) were true positives. Two simultaneous causes: (1)\nshaded seating bowl reads as court-hue in daylight and merges into a\nsingle ~50%-of-frame contour; (2) sunlit apron washes out to saturation\n38 vs. threshold 40.</p><p>v2: reuse the homography fit from the original 16-second rally clip as a\npose check — project the court model's corners onto every reel frame.\nOn-camera frames land pixel-perfect, daytime and evening. 50 interior\nsample points must read court-blue, 17 points just outside the doubles\nalleys must not. Boolean per frame, median-smoothed, segments trimmed to\nruns ≥3s. Result: 60 segments, all starts/ends manually verified.</p><p>Audit of inter-segment gaps: 20 confirmed cutaways, including\nsideline/net-cam replays (fail cleanly — different camera pose). One\nfalse negative: early-set tighter zoom (131s), interior probes still hit\nbut the enlarged court swallowed the apron-margin probes. Not yet fixed:\nrefit homography per block rather than globally; the fitting step is\nalready automatic.</p><p>Plot twist: segment 51 = 59.6s, the match's 49-shot rally (its\nYouTube-title fame). The original tracked-rally clip is a 16-second\nexcerpt from its middle.</p><p>Session cost: $0.00 — HSV thresholding + 67 pixel probes/frame (50\ninterior + 17 exterior). Project total: ~$0.75.</p><p>Open issue: reel is 25fps; the hit/bounce detector was tuned at 30fps,\nand its velocity thresholds are secretly per-frame deltas, not\nper-second — fps needs to become an explicit parameter before that\ndetector runs on these clips.</p><p>Chart state: <code>?f2f1f1b2f2f2b??</code> — leading <code>?</code> = unknown serve zone,\nseven letter+number pairs (<code>f2 f1 f1 b2 f2 f2 b?</code>) = shots called last\nsession (shot 7's direction still open), trailing <code>?</code> = unknown ending\ncode.</p></div></div></details>\n<p>This session cost nothing — no AI calls, just color math and pixel\nchecks. Project total: still about seventy-five cents. One thing to fix\nbefore next time: this reel plays at 25 pictures a second, while the\nhit-and-bounce detector was built on a clip that ran at 30, and its\nspeed cutoffs quietly assume that number.</p>\n<p>Every blank left in the chart — the serve, the last shot's direction,\nhow the point ends — now lives inside these sixty clips, with a 49-shot\nmonster in the middle waiting to stress-test everything downstream.\nNext: point anatomy — finding the serve inside a segment, and the ending\nafter the last bounce.</p>",
            "url": "https://optic-yellow.com/blog/m3-the-cutting-room",
            "title": "M3: The Cutting Room",
            "summary": "Most of a tennis broadcast isn't the match — the software has to spot the one camera angle that actually shows it.",
            "date_modified": "2026-07-09T23:45:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        },
        {
            "id": "https://optic-yellow.com/blog/m3-who-hit-that",
            "content_html": "<p><em>That clip up top: both players outlined and tracked, FOREHAND or BACKHAND\ncalled out at every swing in the hitter's color, feet moving live on the\ncourt diagram — and along the bottom, the shot-by-shot notation typing\nitself out.</em></p>\n<p>Call a shot forehand or backhand and you're answering a geometry\nquestion: which side of the hitter's body did the ball come off? You\ncan't answer that without knowing where the player was standing — and\nuntil now, this project had tracked the ball but never found the\nplayers. Too much for one sitting: session one of several, starting\nwith finding the players.</p>\n<p>Here's what came before. This project's whole bet: feed it broadcast\ntennis video, get back the exact notation <a href=\"https://tennisabstract.com/charting/meta.html\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">Match Charting\nProject</a> volunteers\nhand-write for real matches — the <a class=\"\" href=\"https://optic-yellow.com/blog/auto-charting-kickoff\">kickoff\npost</a> has the pitch. <a class=\"\" href=\"https://optic-yellow.com/blog/m0-first-tracked-rally\">Session\none</a> tracked the game ball through a\nrally for about sixty cents, once shown a box around it. Next, the\n<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\nlines</a> — turns a TV-picture spot into a\ncourt-diagram spot, except anything airborne, which converts to its\nshadow's position instead. Then <a class=\"\" href=\"https://optic-yellow.com/blog/m2-hits-and-bounces\">hit-and-bounce\ndetection</a> read events out of kinks in the\nball'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\nreturn serve</a> — producing the first rough\nchart: <code>??2?1?1?2?2?2???</code>.</p>\n<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>\n<p>Here's the trick, older than computer vision — it's just averaging.\nStack dozens of photos of the same stretch of court, one per frame. Add\nup how often each spot is occupied: spots that barely change are the\ncourt itself, net and lines and grass; spots that keep changing are\nsomething moving through the scene.</p>\n<p>Treat the barely-changing version as a stand-in photo of an empty court,\ncompare every real frame to it, and whatever's different is a player.\nThat's <strong>background subtraction</strong>. (<a class=\"\" href=\"https://optic-yellow.com/primer#background-subtraction\">More in the\nprimer.</a>) This project already ran the\nsame trick once, to erase the players and get a clean court photo for\n<a class=\"\" href=\"https://optic-yellow.com/blog/m1-pixels-to-meters\">measuring its lines</a> — today, in reverse.</p>\n<p>The catch: the trick only knows \"moves\" or \"doesn't move.\" Stand still\nlong enough on camera and you get folded into \"the court\" — a line\njudge at parade rest for a whole rally erases himself as completely as\nthe baseline.</p>\n<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>\n<p>The plan: draw a box around each player in one frame and let SAM 3 —\nthe promptable \"find that object\" AI from <a class=\"\" href=\"https://optic-yellow.com/primer#wasb-vs-sam\">the\nprimer</a> — track each box through the clip, same\ntrick that caught the game ball in <a class=\"\" href=\"https://optic-yellow.com/blog/m0-first-tracked-rally\">session\none</a>.</p>\n<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>\n<p><em>The two boxes, drawn by eye and checked before spending real money. The\nline judge, in red, stands just outside the far box — deliberately left\nout.</em></p>\n<p>Two boxes went in. One came back — a single rectangle per frame, the\nunion of both players, stretched wide enough to swallow the line judge\nbehind Gasquet too. Same lesson as session one: \"tennis ball\" once\nmatched every ball; now two boxes merged into one.</p>\n<p>Useless boxes, but the response also carries the raw shape data behind\nthem — an outline of what the AI thinks belongs to something. Split it\napart wherever it stops touching itself, and every frame resolves into\nthree shapes: Zverev, Gasquet, the line judge.</p>\n<p>Two of those are players. Finding the third is background subtraction\nagain: across the rally, add up how often each spot is covered by a\nshape, as a percentage. Anything covered more than 85% of the time\nbelongs to someone who never left — the judge, at parade rest\nthroughout. Subtract it, and two players are left.</p>\n<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>\n<p><em>The one figure the AI outlined that never moved. Noticing that is the\nentire trick for telling a line judge from a player.</em></p>\n<p>He erased himself. Nobody taught the software what a line judge looks\nlike. The only rule: if it doesn't move, it isn't playing.</p>\n<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>\n<p>What's left splits into near player and far player, by which half of\nthe frame each shape's feet land in.</p>\n<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>\n<p><em>Both players, every frame of the rally. The two short flat patches\naround frames 390 and 425 are the tracker briefly wandering onto\nanother far-side figure, both after the rally had ended, but on the\nrecord.</em></p>\n<p>Result: both players tracked across all 480 frames, zero gaps. The ball\nitself, tracked in session one, only managed 470 of 480, with ten\ndropouts — easier for this AI than a small, fast ball.</p>\n<p>Small twist on an old caveat: the homography only tells the truth for\nthings touching the ground — an airborne ball converts to its shadow's\nposition, not its own. Feet are always on the ground, so a player's\nposition comes out exact, every frame. The most trustworthy dots in\nthis project belong to the players now, not the ball.</p>\n<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>\n<p>With both players placed, deciding who struck a ball has three checks:\nwhich player was closest at the hit; which end of the court it was\nstruck from, the same read used last session for hits and bounces; and\nthe simple fact that a rally alternates — whoever didn't hit last, hits\nnow. All three agreed, on all seven hits — three signals nobody tuned\nfor this question, landing on the same answer anyway.</p>\n<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>\n<p>Once a player's position is trustworthy, calling the shot needs no more\nAI — it's geometry. Ball on the right of a right-hander at contact is a\nforehand, on the left a backhand — the same call from the couch.</p>\n<p>One wrinkle: the camera-facing player has left and right flipped, so\nhis measurement gets mirrored. One liability: handedness is assumed,\nnot detected — a left-handed player would get every call flipped, a\ndebt the series collects later.</p>\n<p>The verdict: forehand, forehand, forehand, backhand, forehand,\nforehand, backhand — seven shots, seven calls, checked by pulling the\nvideo frame for every swing, the eyes-over-math discipline that saved\n<a class=\"\" href=\"https://optic-yellow.com/blog/m2-hits-and-bounces\">last session</a>.</p>\n<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>\n<p><em>Shot seven, the rally's last swing: Gasquet's one-handed backhand,\nracket coiled high before he sweeps through it. If you're going to\neyeball-check a backhand, make it his.</em></p>\n<p>Seven for seven — but one deserves an asterisk. On shot three, the ball\ncrossed so close to Gasquet's body that the measurement came out just\n1.5 pixels to the forehand side — a coin-flip margin. The video settles\nit: a low pickup off his right shoe, unmistakably a forehand. Right,\nbut only because the coin landed the correct way up.</p>\n<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>\n<p>The two versions of this rally's chart, side by side:</p>\n<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>\n<p>Read the finished string left to right:</p>\n<ul>\n<li class=\"\">The first <code>?</code> is the serve — unknown, since this clip never shows\none.</li>\n<li class=\"\"><code>f2 f1 f1 b2 f2 f2 b</code> are the seven shots, in order: forehand,\nforehand, forehand, backhand, forehand, forehand, backhand — this\nsession's addition. The digits (settled last session) mark which\nthird of the court each shot crossed into. Shot seven has no digit —\nit ends the point, coded differently.</li>\n<li class=\"\">The last two <code>?</code> marks are where the ball landed and how the point\nended — also missing from this clip.</li>\n</ul>\n<p>The chart also gained something the earlier version couldn't show:\nwhere each player was standing when they hit.</p>\n<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>\n<p><em>Seven arrows — who hit it, which wing, from where, to where. Dashed\narrows land on the far side, still accurate only to within a meter or\nso.</em></p>\n<p>Zverev played right around his baseline; Gasquet, more than three\nmeters behind his — court positioning anyone who's watched him play\nwould recognize.</p>\n<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>\n<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>\n<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>\n<p>Stand still long enough on a tennis court, and even a computer starts\nto think you're part of the furniture.</p>\n<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>\n<li class=\"\">\n<p><strong>The box-merge failure.</strong> SAM 3's two-box prompt returned one\nunioned bounding box per frame, not two — same \"prompts fuse into one\nconcept\" behavior as the text prompts in session one; per-object\nstructure doesn't survive a single API call. The box's right edge\nlanded at roughly pixel 807, exactly far enough to include the line\njudge. The response does include the raw run-length-encoded (RLE)\nmasks — space-separated start/run pairs over the flattened frame,\nundocumented like the box format. Decoded and split into connected\ncomponents, each frame resolves into three spatially disjoint blobs\n(Zverev, Gasquet, line judge); nobody occludes anybody else in image\nspace.</p>\n</li>\n<li class=\"\">\n<p><strong>The static filter.</strong> For every pixel, the fraction of the 480\nframes it's covered by any mask (<code>np.mean</code> over the stacked binary\nmasks, time axis). Anything \"on\" over 85% of the time is treated as\nstatic and subtracted from every frame's mask before splitting the\nrest into players — the same averaging that built the player-free\ncourt photo for the homography, run in reverse.</p>\n</li>\n<li class=\"\">\n<p><strong>The forehand/backhand formula.</strong> Shot type = sign of (ball\nx-position minus striker's blob-center x-position) at contact,\nmirrored for the far player. Handedness is assumed, not detected.\nShot 3's raw offset was −1.5 pixels — inside the noise floor of a\nsingle pixel of foot-tracking error, essentially arbitrary before\nvideo confirmed it; needs a confidence threshold and a bodyline\npolicy before an untuned rally. Position precision differs by side:\nnear-side (Zverev, whose three shots came from 0 to 1.3 meters behind\nhis baseline) converts with high confidence; far-side (Gasquet) is in\nmeters-per-pixel territory, so his \"more than three meters behind the\nbaseline\" is directionally solid, not precise.</p>\n</li>\n<li class=\"\">\n<p><strong>Session accounting.</strong> One paid API call (the two-box SAM 3 prompt);\neverything after ran locally at no cost.</p>\n</li>\n</ul></div></div></details>\n<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>\n<p>Session cost: $0.15 — one API call, everything after it just\narithmetic on numbers already in hand. Running total: about\nseventy-five cents.</p>\n<p>The remaining question marks didn't move — the serve, where the ball\nlanded, how the point ended, all still blank, since a mid-rally\nhighlight can't show a point starting or ending. Next session: better\nsource footage — full match video, complete points, camera cuts and\nall.</p>\n<p>The chart still reads <code>?f2f1f1b2f2f2b??</code> — seven question marks fewer\nthan it started with, three still to go. Time to earn the rest of the\npunctuation.</p>",
            "url": "https://optic-yellow.com/blog/m3-who-hit-that",
            "title": "M3: Who Hit That?",
            "summary": "The software found the players by noticing who never moved, then called every forehand and backhand from where the ball crossed their body.",
            "date_modified": "2026-07-09T23:00:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        },
        {
            "id": "https://optic-yellow.com/blog/m2-hits-and-bounces",
            "content_html": "<p><em>That clip up top: the pipeline calling out HIT and BOUNCE in real time,\npinning every one onto a court diagram the instant it happens.</em></p>\n<p>The last post ended with a warning that turned into today's homework: the\nsystem can only trust the ball's screen position at the exact moments it's\ntouching the court — everything else is guesswork. So today's question:\nwatching nothing but the ball's path across the screen, can software find\nevery hit, and every bounce?</p>\n<p>The detector I built today ended up calling all thirteen of those moments\ncorrectly. It got there by being wrong twice first, and both mistakes\ntaught me more about how a tennis ball moves than the successes did.</p>\n<p><strong>Previously:</strong> <a class=\"\" href=\"https://optic-yellow.com/blog/m1-pixels-to-meters\">last time</a>, the software\nlearned to convert \"where the ball is on your TV screen\" into \"where the\nball is on the actual court\" — the same trick a fan does by eye, guessing\nwhere a shot landed. The catch, carried straight into today: that\nconversion only tells the truth for things touching the ground. A ball in\nmid-air converts to the position of its <em>shadow</em>, not the ball itself. So\nthe software needs to know exactly when the ball is on the ground before\nit can trust anything else it says.</p>\n<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>\n<p>A ball in a rally does exactly two things, over and over, and they look\nnothing alike in its path.</p>\n<p>When a player <strong>hits</strong> the ball, it reverses direction — back the way it\ncame, sharp and immediate. When the ball <strong>bounces</strong>, nothing reverses. It\nkeeps traveling the same way, just lower and slower. A racket sends the\nball backward; the court just takes some steam off it and lets it keep\ngoing. That's the whole idea in one line: <strong>the court doesn't return\nserve.</strong> (More on reading events out of a flight path: <a class=\"\" href=\"https://optic-yellow.com/primer#hits-and-bounces\">Court Vision in\nPlain English</a>.)</p>\n<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>\n<p>First move: turn the tracked dots into a graph of position and speed over\ntime, and look at it.</p>\n<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>\n<p><em>The ball's up-down screen position, how fast that's changing, and how\nfast its shadow moves lengthwise down the court — across the rally's 290\nusable frames (each a single still picture — more in <a class=\"\" href=\"https://optic-yellow.com/primer#pixels-and-frames\">Pixels and\nframes</a>), with 10 short gaps where the tracker\nbriefly lost the ball.</em></p>\n<p>Two traps, both from camera angle. First: a ball's up-down position on\nscreen isn't the same as its real height — a ball at the far baseline\nstill looks \"high\" simply because it's small and distant on screen — the\npretty arcs you'd expect a bounce to draw aren't real; they're height and\ndistance tangled together. Second, and stranger: the shadow's sideways\nspeed spikes to the equivalent of 200 meters per second when the ball is\nhigh on the far side. Nothing on court moves that fast — it's the camera\nexaggerating airborne motion.</p>\n<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>\n<p>My first version found every cusp in the ball's up-down speed — where it\nstops falling — then called hit or bounce by whether direction flipped.\nIt found nine such moments and called eight of them hits.</p>\n<p>That's not how tennis works — almost every stroke is preceded by a\nbounce. Something was badly wrong, and the fix wasn't better math. It was\ndoing what a chair umpire does with a disputed call: stop arguing and\n<strong>go look at the replay.</strong> I pulled the actual video frames around every\nflagged moment.</p>\n<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>\n<p><em>Frame 44, flagged as a hit. The ball is at Zverev's feet; his racket is\nnowhere near it. That's a bounce.</em></p>\n<p>Watching those frame strips built a hand-verified answer key for the\nrally, and exposed two ways the detector was fooling itself.</p>\n<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>\n<p><strong>Near-side bounces looked like hits.</strong> Right after a real bounce, the\nshadow speed collapses into a small, noisy number whose sign flips often\nenough to trick the direction test. Fix: stop asking whether direction\nflipped; ask how fast the ball leaves instead. A real return leaves fast;\na bounce's outgoing speed collapses toward zero.</p>\n<p><strong>Far-side bounces were invisible, not just hard.</strong> After a far bounce,\nthe ball rises and recedes at once — both push its screen position the\nsame way, so there's no cusp to find at all. What does exist: the same\nshadow-speed exaggeration from before. Airborne, the far shadow races at\nwildly inflated speed; the instant the ball lands, that exaggeration\nvanishes and the shadow slows near the truth. That collapse marks the\nbounce, even where nothing in the picture looks like one.</p>\n<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>\n<p>The first version of that collapse-detector immediately flagged a moment\nmid-rally as a bounce. The video showed the ball sailing over the net, a\nmeter and a half up — nowhere near bouncing.</p>\n<p>It took a minute to understand: the collapse isn't instant at ground\ncontact — it unwinds gradually through the descent, fading as the ball\nloses height. An early \"slowing down\" is just the ball still falling.\n<strong>The bounce is the last collapse of a descent, not the first.</strong></p>\n<p>Re-checking frames after that fix surfaced one more find, and it wasn't\nabout the ball: my own hand-built answer key had quietly stopped early,\nbefore the rally actually ended. The tracked data ran further — one more\nfar bounce, then Gasquet's final swing, both missed on my first pass. Even\na hand-checked answer key needs its own second check.</p>\n<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>\n<p><em>The finished detector: seven hits, six bounces, all thirteen matching the\nvideo.</em></p>\n<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>\n<p>Thirteen events found, checked against video, correctly told apart as hit\nor bounce — zero wrong calls. Because the screen-to-court conversion\nalready exists, each one drops onto an actual scale drawing of the court:</p>\n<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>\n<p><em>Thirteen dots and X's on a court diagram. Solid yellow (near-side\nbounces) are trustworthy to within centimeters. Hollow dots (far-side)\nare only good to within a few meters — the same camera-angle problem as\nabove.</em></p>\n<p>The near-side bounces landed at 19.4, 19.6, and 20.4 meters down the court\n— about three and a half meters inside the baseline, exactly where pro\ngroundstrokes land. Nobody told the software that; it just fell out of the\nmath.</p>\n<p>Read the sequence out loud: <em>hit, bounce, hit, bounce, hit</em> — a rally,\ndescribed shot by shot. No shot types or directions yet, but for the\nfirst time this reads like the start of a match chart, not a vision demo.</p>\n<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>\n<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>\n<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>\n<p>A hit throws the ball back where it came from; a bounce just lets it keep\ngoing, a little slower. That one difference — reversed or not — is enough\nto read a whole rally's worth of events out of a wobbly, camera-distorted\nflight path. The court doesn't return serve.</p>\n<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:\nimage-y mixes height with court depth (M0's \"parabolas\" are projection\nmixtures); along-court shadow velocity spikes to ±200 m/s when the ball is\nhigh on the far side.</p><p>Detector v1: cusps in vertical velocity, classified by direction flip.\nFound 9 events, called 8 hits. Root-caused by pulling video frames at\nevery event rather than debugging further — frame 44 flagged as a hit\nshowed the ball at Zverev's feet, racket nowhere near it.</p><p>Failure 1, near-side bounces classified as hits: post-bounce shadow\nvelocity collapses into noise (-2 to -4 m/s) and its sign flips, fooling\nthe direction test. Fix: classify on outgoing speed, not sign — real\nreturns leave at 5+ m/s (often 40); bounces collapse toward zero.</p><p>Failure 2, far-side bounces structurally invisible: post-bounce the ball\nrises and recedes simultaneously, both pushing image position the same\nway — no cusp exists in that coordinate. Fix: shadow-speed collapse —\nairborne shadow runs -30 to -50 m/s of amplified nonsense, on-ground\nshadow runs true speed (~-5).</p><p>False positive at frame 71: collapse detector v1 flagged it as a bounce;\nvideo shows the ball crossing the net, 1.5m up. Root cause: collapse\nunwinds gradually through the descent — early collapse candidates are\ndescent-in-progress, not landing. Fix: bounce = <em>last</em> collapse of a\ndescent, not first.</p><p>While re-verifying, found my own ground truth had stopped at frame 243\nwhile the track runs to ~298 — one more far bounce, then Gasquet's final\nswing, found by the detector, missed by the manual labels.</p><p>Detector v4 (final): 7 hits, 6 bounces, 13/13 matched against\nframe-verified ground truth, 13/13 classified correctly, 0 false\npositives. Near-side bounce depths: 19.4, 19.6, 20.4m — ~3.5m inside the\nbaseline, consistent with pro groundstroke depth.</p><p>Caveats: thresholds tuned to one clip, generalization untested. Far-side\npositions good only to a few meters — at the far baseline, one frame of\ntiming error is meters of shadow, and the ball is already moving again by\nthe next frame. Near-side positions look genuinely good.</p></div></div></details>\n<p>Running total across the first three builds — an object tracker, a\nscreen-to-court conversion, a frame-verified event detector — is still 60\ncents, all spent on the first one. The other two were just math run over\ndata already collected.</p>\n<p>Next: turning this sequence of events into an actual chart — splitting\nfootage into rallies, figuring out shot direction, and writing it all out\nin the Match Charting Project's own notation. That's the point where this\nstops being a computer-vision project and starts being a tennis project.</p>",
            "url": "https://optic-yellow.com/blog/m2-hits-and-bounces",
            "title": "M2: The Court Doesn't Return Serve",
            "summary": "Teaching software to spot every hit and bounce in a rally, just from watching the ball's path bend.",
            "date_modified": "2026-07-09T21:00:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        },
        {
            "id": "https://optic-yellow.com/blog/m1-pixels-to-meters",
            "content_html": "<p><em>That clip up top: the yellow box tracking the ball on the broadcast\npicture, next to the same rally redrawn as dots on a plain, top-down\ncourt diagram — the ball's real position, in meters, drawn live as the\nrally plays out.</em></p>\n<p>Last time, the software could hold onto a tennis ball for an entire\nrally on screen. Today's question: can it tell you whether that ball\nlanded in the court, or a foot wide of it?</p>\n<p><strong>Previously:</strong> the <a class=\"\" href=\"https://optic-yellow.com/blog/auto-charting-kickoff\">kickoff post</a> set the\ngoal — teach software to watch a broadcast match and produce the\npoint-by-point notes volunteers hand-type for the <a href=\"https://tennisabstract.com/charting/meta.html\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">Match Charting\nProject</a>, a slow,\nhuman-powered dataset the project wants to speed up. <a class=\"\" href=\"https://optic-yellow.com/blog/m0-first-tracked-rally\">Last\ntime</a>, an AI followed one specific ball\nthrough a whole rally for about sixty cents. What came back was a list\nof screen positions — pixel coordinates, the colored dots a video frame\nis built from (more in <a class=\"\" href=\"https://optic-yellow.com/primer#pixels-and-frames\">Court Vision in Plain\nEnglish</a>). A screen position alone can't say\nwhether a shot landed in or well wide.</p>\n<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>\n<p>You already do this without noticing. Watch a match on TV, and when a\nball lands, you can point to roughly the right spot on a paper court\ndiagram — even though the camera sits up high behind the baseline, so\nthe far side of the court looks small and squeezed while the near side\nlooms huge. Your brain quietly un-squeezes it. That un-squeezing rule —\nturning \"this spot on the TV screen\" into \"this spot on the court\" — is\nwhat computer vision calls a <strong>homography</strong>, the screen-to-court-diagram\nconversion (<a class=\"\" href=\"https://optic-yellow.com/primer#homography\">more on it here</a>). Software needs the\nrecipe spelled out, and it gets one the way a chair umpire settles an\nargument: from the court's own painted lines. Match what the camera sees\nto the real dimensions — every court on earth is 10.97 by 23.77 meters —\nand the conversion falls out.</p>\n<p>One catch to tuck away first: this recipe only works for things sitting\n<strong>on the ground</strong>. It converts the court's flat surface, not the air\nabove it. A player's feet are on the ground, so their position converts\nhonestly. A ball in flight isn't touching the ground, so the same recipe\nreports where the ball's <em>shadow</em> would be — not the ball. Hold onto\nthat; it's the twist waiting at the end of this post, and it echoes\nthrough the rest of the series.</p>\n<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>\n<p>Finding painted lines is easier once you clear the players off them. The\ntrick: the broadcast camera holds still for the whole rally (checked in\nthe appendix). Stack every frame of the clip, and for each dot on\nscreen, take the middle value it shows across every frame — its median.\nA player is only ever briefly at any one dot, so the middle value votes\nthem out of existence. The lines never move, so they survive razor\nsharp. It's the same free trick the <a class=\"\" href=\"https://optic-yellow.com/primer#background-subtraction\">Plain English\nprimer</a> uses to explain how the\npipeline later finds players — here it just clears the stage.</p>\n<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>\n<p><em>A tennis court with nobody playing tennis on it — built by stacking\nframes that all had somebody on it.</em></p>\n<p>One line of code, and both players vanish, the ball vanishes, the lines\nstay razor sharp. A couple of line judges, who barely move, leave faint\nghosts, and the on-screen graphics stick around too — neither matters\nfor what's next.</p>\n<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>\n<p>Court lines are white paint on a blue court. So: isolate the big blue\nrectangle first, and only look for white dots <em>inside</em> it. That one rule\nkeeps the crowd's white shirts, courtside signage, and the scoreboard's\ntext out of the search entirely; the blue court fills about a quarter of\nthe frame.</p>\n<p>Run a standard line-finding routine on what's left, and 48 short\nsegments come back, covering every painted line on the court.</p>\n<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>\n<p><em>Every line the court owns — plus one it doesn't.</em></p>\n<p>Every real line, except the search also grabs an impostor: <strong>the net\ncord reads as a line too.</strong> It's white, roughly horizontal, and\nstretches most of the way across the frame — but it isn't painted on\nthe ground, and it isn't even straight; a net sags under its own\nweight. Any scheme that just grabbed the horizontal lines would trip\nright over it.</p>\n<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>\n<p>The fix is almost embarrassingly simple. Group the found lines into\nfamilies — side-to-side, toward-the-camera — and keep only the\noutermost member of each: the top and bottom side-to-side lines are the\nbaselines, the left and right toward-the-camera lines are the outer\nsidelines. The net sits in the middle by definition, so it can never be\nan outer line; it's excluded automatically. Cross those four outer\nlines and you get the court's four corners. Hand those four points, plus\nthe court's real dimensions, to a standard piece of geometry software,\nand out comes the homography.</p>\n<p>Four corners is about as little as a homography can be built from. Is\nit any good? The honest test: three pairs of lines never went into\nbuilding the conversion at all — the singles sidelines, both service\nlines, and the center line. Run the full court diagram back through the\nconversion and see where those untested lines land.</p>\n<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>\n<p><em>Yellow: the lines the conversion was built from. Orange: lines it had\nnever seen before this moment — landing right on the paint anyway.</em></p>\n<p>They land almost exactly on the paint, off by less than one screen dot\nevery time. From four corners, on the first try. I had a refinement step\nplanned in case the numbers came back rough, and deleted it.</p>\n<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>\n<p>Now the payoff: take last post's ball positions — one screen dot per\nframe — and run every one through the new conversion, then plot them on\na true-to-scale court diagram.</p>\n<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>\n<p><em>The same rally, seen from directly above for the first time. Color\nmarks time.</em></p>\n<p>Most of it reads like tennis: the ball crossing the net, dots clustering\nlow near the baselines where players make contact. Some of it doesn't —\na handful of points land more than 18 meters past the far baseline, well\noutside any real court. Not a fit error; the untested lines above just\nproved this conversion is accurate to a fraction of a screen dot. It's\nthe catch from earlier, on schedule: <strong>the conversion only knows the\nground</strong>, and a ball spends most of a rally in the air. What this chart\ndraws, for every airborne moment, is the ball's shadow, not the ball.\nThe shadow is truthful at exactly one instant — when the ball touches\ndown — and drifts further from reality the higher it flies.</p>\n<p>Which hands the next post its whole job: find the moments the ball\ntouches the ground — the bounces — because those are the only moments\nthis chart isn't lying.</p>\n<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>\n<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>\n<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>\n<p>The ball — tennis's famous hard problem — needed a paid AI and a human's\nmouse click to follow. The court — the part that sounds like real\ncomputer vision — needed four corners and free arithmetic. By the end\nof the day, the hard problem and the easy problem had traded chairs.</p>\n<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>\n<li class=\"\">Camera stability: edge IoU 0.83, sharpness ratio 0.90 (median frame\nvs. frame 0) — near 1.0, no pan, one homography covers all 480 frames.\nMedian of 96 sampled frames via <code>np.median(frames, axis=0)</code>.</li>\n<li class=\"\">Court-surface isolation: largest blue contour = 26.5% of frame;\nwhite-pixel search restricted to inside it.</li>\n<li class=\"\">Line detection: Hough transform on masked white pixels → 48 segments,\nclustered into 5 horizontal + 5 vertical families.</li>\n<li class=\"\">Corner selection: extremes of each family (top/bottom horizontal =\nbaselines, left/right vertical = doubles sidelines) → 4 corners →\nOpenCV perspective transform against official dimensions (10.97 ×\n23.77 m).</li>\n<li class=\"\">Validation: singles sidelines, both service lines, center line held\nout entirely; reprojection mean error 0.0–0.8 px on those held-out\nlines.</li>\n<li class=\"\">Net cord excluded automatically — never an extreme of either family.\nThe fitted model's net line is the net's dead-straight ground\nprojection; the real net cord sags visibly above it.</li>\n<li class=\"\">M0's per-frame ball centroid pushed through the fitted homography onto\nthe true-scale court model; some points land &gt;18 m past the far\nbaseline mid-flight — a ground-plane-only mapping meeting an airborne\nball, not fit error (confirmed by the sub-pixel held-out-line result\nabove).</li>\n<li class=\"\">Total cost: $0.00 — zero manual clicks, zero API calls; numpy and\nOpenCV, run locally.</li>\n<li class=\"\">Carried to M2: read hit/bounce events from the discontinuities in this\nsame trajectory.</li>\n</ul></div></div></details>\n<p>This step cost nothing — no clicks, no paid API calls, just code on my\nown laptop. Total for the milestone: $0.00.</p>\n<p>Next question: the ball's path on screen is smooth curves broken up by\nsudden kinks. Is every kink a hit or a bounce? Time to find out if that\nidea survives contact with real data.</p>",
            "url": "https://optic-yellow.com/blog/m1-pixels-to-meters",
            "title": "M1: From Pixels to Meters",
            "summary": "How four painted corners taught the software to turn a TV picture of the court into a real, to-scale court diagram — for free.",
            "date_modified": "2026-07-09T18:00:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        },
        {
            "id": "https://optic-yellow.com/blog/m0-first-tracked-rally",
            "content_html": "<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>\n<p>Before software can chart a single stroke of tennis, it has to do the\nsimplest thing a ball kid does without thinking: watch the right ball, the\nwhole rally through.</p>\n<p>Short answer: yes, it can — with a catch, and getting there took four tries\nand two dead ends. Here's how.</p>\n<p>The <a class=\"\" href=\"https://optic-yellow.com/blog/auto-charting-kickoff\">kickoff post</a> laid out the plan: teach\nsoftware to watch broadcast video and produce the same shot-by-shot chart\nthat Match Charting Project volunteers write by hand today — who served\nwhere, how the rally went, who won the point. This post is step one:\nbefore anything smarter can happen, the software has to prove it can keep\nits eyes on one ball.</p>\n<p>Picture the court between points: the ball actually in play, plus every\nspare — tucked in ball kids' hands, a player's pocket, by the net post.\nShout \"watch the ball!\" and anyone would reasonably ask which one. That's\neffectively the instruction the AI got on the first attempt: the words\n\"tennis ball.\" It's what's called a <em>promptable</em> model — instead of\ntraining it on tennis ahead of time, you just tell it, in the moment,\nwhat to look for. Told to find \"tennis ball,\" it found all of them, game\nball included, and drew one box around the whole group. That's matching a\ndescription, fresh, everywhere it fits — not following one ball.</p>\n<p>The fix: point instead of describe. Draw one box, once, around the <em>one</em>\nball actually in play, in a single picture. That's a different kind of\ninstruction — not \"find anything that looks like this\" but \"follow this\nexact object\" — and the model carries that one ball forward through the\nvideo, remembering which ball is the right one from picture to picture.\nThat's called <strong>tracking</strong>: locking onto one specific object and\nfollowing it through the clip, rather than spotting fresh matches from\nscratch each frame. (More on the difference, with more tennis examples:\n<a class=\"\" href=\"https://optic-yellow.com/primer#detection-vs-tracking\">Court Vision in Plain English</a>.) A text\nprompt matches every instance of a concept in the picture; a box drawn\naround one specific ball tracks that ball, and only that ball.</p>\n<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>\n<p>I grabbed a 16-second broadcast rally — Zverev vs. Gasquet, in Montreal —\nto test that idea on real footage. Finding a usable clip was its own\nsmall project: the first highlight compilation kept cutting away from\nthe rally to close-ups and crowd shots after a few seconds. It took two\ntries to find one continuous shot of a full rally from a single camera.</p>\n<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>\n<p>I asked the AI to find \"tennis ball, tennis player.\" The picture looked\nright for the first several seconds — clean outlines around both players.\nThen, partway through the clip, it abruptly flipped: instead of the\nplayers, it was now outlining the entire court. The model had lost its\ntrain of thought and locked onto something else entirely. (Why, and\nexactly when, is in the appendix.)</p>\n<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>\n<p>Dropping the players and asking only for \"tennis ball\" did return one box\nfor every single picture in the clip — 480 of them, which sounded\npromising. Then I plotted where that box actually sat, frame by frame:</p>\n<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>\n<p><em>The box the AI called \"the ball,\" over time. Long flat stretches — a\nball in a live rally never sits still like that.</em></p>\n<p>A ball in play is never still for long; this chart was, flat stretch\nafter flat stretch. Drawing the box back onto the video explained why: it\nwasn't tracking one ball, it was covering all of them. Broadcast courts\ncommonly have something like six balls in view at once — the game ball\nplus every spare a ball kid is holding — and the AI had drawn one\nenormous box around the entire group, roughly the size of half the court.</p>\n<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>\n<p><em>The actual game ball — a smudge maybe eight pixels wide, moving at\n200 kilometers an hour, contributing almost nothing to a box that size.</em></p>\n<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>\n<p>New plan: skip the words, point at the object. I found the game ball by\neye in one frame, drew a box around it, and let the model carry that ball\nforward and backward through the rest of the clip on its own. Most of the\nclip came back with sensible, ball-sized boxes — a real improvement. Then\nthe very first numbers I decoded came out negative, which a box's width\ncan never be. Turned out to be a units mix-up, not a tracking failure\n(full story in the appendix). Read correctly, the numbers lined up\nexactly on the ball I'd pointed at.</p>\n<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>\n<p>With the numbers read correctly, the tracked path finally looked like a\nreal rally:</p>\n<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>\n<p><em>The game ball's tracked position, after pointing at it in one frame and\nletting the model follow it both ways through the clip.</em></p>\n<p>Three clean trips of the ball from baseline to baseline, with a sharp\nreversal in direction at each hit — exactly the shape a ball traces when\nit's actually being struck back and forth. Those reversals are exactly\nthe signal a later step will use to work out precisely when each hit\nhappened.</p>\n<p>Two surprises. I'd only pointed at the ball in one <em>middle</em> frame, yet the\nmodel filled in its position both backward and forward on its own. And\ntrack death has a clean tell: once the ball leaves play, the tracked box\ndoesn't vanish — it freezes in place and balloons to several times its\nnormal size. A dead track looks like a frozen, oversized box, as readable\na signal as a late line call. (Exactly where, and the fix, is in the\nappendix.)</p>\n<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>\n<p><em>One frame from the rally, tracked path drawn in — the box sits on the\ngame ball, not a ball kid's spare.</em></p>\n<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>\n<p>First milestone: done, at least for the ball — which was the part I\ngenuinely doubted going in.</p>\n<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>\n<p>Two things are still open, not quietly skipped. Players aren't tracked as\ncleanly as the ball yet — the same fix should work, but that's a guess,\nnot a result. And the box is still hand-drawn: a person clicks the ball\nonce per rally to start the pipeline. Workable, but not the automation\nthis project needs. Both, in depth, are in the appendix below.</p>\n<p>Tell an AI to watch \"the tennis ball\" on a broadcast court and it will\ndutifully watch every ball in the picture at once. Point at the one that's\nactually in play, and it will follow just that one, the whole rally\nthrough.</p>\n<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:\nfal.ai's hosted SAM 3 endpoints —\n<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\nvideo) 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>\n(RLE — run-length-encoded — masks plus per-frame boxes). About half a\ncent per 16 frames, no GPUs to manage.</p><p><strong>Attempt 1 detail.</strong> Prompt <code>\"tennis ball, tennis player\"</code>, both\nendpoints. Player segmentation looked clean for ~8 seconds — correctly\ncatching the line judges and ball kids too, since they're dressed like\nplayers. Around frame 240 the mask flipped to segmenting the entire\ncourt. Logs: <code>Completed chunk 2/2</code>. fal processes video in chunks; the\nsecond chunk appears to re-run concept detection from scratch and land on\na different match — chunk boundaries are concept-drift boundaries. The\nRLE endpoint, same prompt: one box per frame, a single giant region\nmerging everything matched, every confidence score null.</p><p><strong>Attempt 2 detail.</strong> Prompt <code>\"tennis ball\"</code> alone, detection threshold\n0.3. Returned 480 boxes (one per frame), each the union of every tennis\nball visible in frame — game ball contributing almost nothing to a box\nthat size.</p><p><strong>Attempt 3 detail.</strong> Game ball located by eye in frame 240, pixel (622,\n462); box prompt anchored there. 470/480 frames returned boxes. The\nnegative widths on first parse were a units bug, not a tracking bug:\nbox-prompted responses come back as <code>[cx, cy, w, h]</code> — normalized\ncenter-plus-size — not the <code>[x1, y1, x2, y2]</code> corners the text-prompt\nresponses use. Documented nowhere, as far as I can tell; found by staring\nat 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\nclean flight arcs, propagated in both directions from the single\nframe-240 prompt. Track death: box collapses to a frozen, oversized shape\nonce the ball leaves play around frame 300. Dropping any box wider than\n0.05 (normalized) cleaned that up entirely.</p><p><strong>Open items.</strong> Player tracking: original scope was \"ball and both\nplayers\"; attempt 1's player masks were promising until the chunk-boundary\nbug ate them, and it wasn't revisited once the ball was solved. Players\nare bigger, slower, and hundreds of pixels tall, so box prompts should\nhandle them too, but that's untested. The human-drawn box: still one\nmanual click per rally to seed the track. Candidate fix, for later:\ntext-prompt for every ball in frame, then keep whichever one is moving.</p></div></div></details>\n<p>All in: about sixty cents, four tries, two dead ends. Next question: now\nthat the pipeline can find the ball in a picture, can it work out where\nthat ball sits on the court itself? A different kind of problem — less\nabout winning an AI's cooperation, more plain geometry homework.</p>",
            "url": "https://optic-yellow.com/blog/m0-first-tracked-rally",
            "title": "M0: Can SAM 3 Hold Onto a Tennis Ball?",
            "summary": "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.",
            "date_modified": "2026-07-09T12:00:00.000Z",
            "author": {
                "name": "Trevor McCormick",
                "url": "https://github.com/trevormccormick"
            },
            "tags": [
                "Devlog",
                "Computer Vision"
            ]
        }
    ]
}