Tennis Radar
Turning a phone video into a radar gun.
The idea, the dead-ends, and how it actually works.
← → to navigate · ⌘⏎ fullscreen · S notes · T timer
It starts with a why
I play tennis. When I serve, I want one number: how fast was that ball?
A real need
Serve speed is the headline stat of the game. It's motivating, it's measurable, and you can't improve what you can't see.
A feedback loop
Number after number, session after session — track progress, compare friends, settle the bragging rights.
In my pocket
No hardware to buy or carry. Just the phone that's already in my bag.
The speed is measured everywhere
— just never in my pocket
Roland-Garros
A multi-camera Hawk-Eye tracking system triangulates the ball in 3D. Stunning — but a fixed, expensive installation.
My old club
A handheld Doppler radar gun behind the baseline. Great, until you want one of your own — they're pricey and single-purpose.
The gap
Pro-grade at the top, hardware at the club. Nothing free, in everyone's pocket. That's the opening.
First instinct: detect the ball with AI
Run an object-detection model on each frame, find the ball, track it across time.
- The ball is tiny & motion-blurred — a faint streak at 200 km/h.
- Lighting, courts and backgrounds vary wildly between clips.
- False positives: logos, lines, other players, the sun.
Add body / pose recognition
Detect the player and racket to anchor the scene and help localise the ball.
- More models, more compute, more things to calibrate.
- The measurement was still inconsistent clip to clip.
- Worst of all — a black box.
Drop vision — listen to the impact
The racket-ball contact is a sharp audio transient. So is the ball hitting the court.
- Detect the two onsets in the audio waveform.
- Time between them + a known distance → a speed.
- But: mic quality, echo, wind, crowd, other bounces.
Stop guessing.
Use what the phone already knows.
Time — from the frame rate
A 240 fps clip means every frame is exactly 1/240 s apart. The phone records this precisely.
Distance — from a reference
Tap a real-world object of known length (a racket = 0.685 m) to convert pixels into metres.
No AI. Deterministic, debuggable, and explainable to the user — every input is something they pointed at.
Let's run it on Android
Record or import a slow-mo clip
Trim to the rally
Mark the reference (top & bottom)
Tap the ball on 2 frames
Read the speed 🎯
Live screen mirror — fall back to the clip below if needed.
Three measurements, one division
L, measured in pixels refPx. Gives the scale metresPerPixel = L / refPx.d × scale = real distance in metres.Δt = (t₂ − t₁) / slowMotionFactor, where frame spacing comes from the recorded fps.All the “magic” is one function
// 4 points the user tapped (pixels) + 2 frame timestamps
fun calculateSpeedKmh(): Double? {
val b1 = dotOverlay.ball1 ?: return null // ball, frame A
val b2 = dotOverlay.ball2 ?: return null // ball, frame B
val refTop = dotOverlay.racketTop ?: return null // reference, top
val refBot = dotOverlay.racketBottom ?: return null // reference, bottom
// 1 · Real elapsed time. The clip is slowed `divisor`x,
// so divide by it to recover real-world seconds.
val deltaMs = (timeFrame2Ms - timeFrame1Ms).toDouble() / divisor
if (deltaMs <= 0.0) return null
// 2 · Pixel→metre scale from a reference of known length
// (tennis racket = 0.685 m).
val refPx = hypot(refTop.x - refBot.x, refTop.y - refBot.y)
if (refPx == 0.0) return null
val metresPerPixel = sport.referenceLengthMeters / refPx
// 3 · Distance the ball travelled between the two frames
val ballPx = hypot(b2.x - b1.x, b2.y - b1.y)
val ballMetres = ballPx * metresPerPixel
// 4 · Speed. metres / ms → km/h ( ×1000 ×3600 ÷1000 = ×3600 )
return ballMetres * 3600.0 / deltaMs
}
val speedMph = speedKmh * 0.621371
The slow-motion correction
- A 240 fps clip played at 30 fps is 8× slower than reality.
- Use playback time → speed reads 8× too low. So we divide by the slow-motion factor.
- Frame stepping advances exactly
1000 / fpsms — one true frame at a time. - fps is read from the video track metadata, never assumed.
// SlowMotion factor
enum class SlowMotion(val divisor: Int) {
X1(1), X2(2), X4(4),
X8(8), X16(16)
}
// one frame, in ms
seekDurationMs = (1000f / frameRate)
ExoPlayer — frame-accurate seeking
androidx.media3:media3-exoplayer:1.10.1- SeekParameters.EXACT — land on the exact frame, not the nearest keyframe. Essential for tapping the ball.
- REPEAT_MODE_ONE — loop the trimmed rally while you work.
- A single shared
ExoPlayerinstance, reused across screens.
fun create(context: Context): ExoPlayer {
return ExoPlayer.Builder(context)
.build().apply {
repeatMode = REPEAT_MODE_ONE
setSeekParameters(
SeekParameters.EXACT
)
}
}CameraX — high-speed recording
androidx.camera:*:1.6.1—core,camera2,lifecycle,video,view.- Recorder + VideoCapture use case for clips.
- HighSpeedVideoSessionConfig unlocks 120/240 fps slow-mo.
- Camera2 interop pins the frame duration via
CaptureRequestOptions. - Import path too — read fps from any gallery slow-mo clip.
// pin the frame rate (fps)
Camera2CameraControl
.from(cameraControl)
.setCaptureRequestOptions(
CaptureRequestOptions.Builder()
.setCaptureRequestOption(
CONTROL_AE_TARGET_FPS_RANGE,
Range(240, 240)
).build()
)Hand-drawn custom views
WorkspaceSeekbarView
A View with a custom onDraw(Canvas). Two modes:
- TRIM — two draggable thumbs bound the rally.
- SEEK — frame-precise scrub with a time tooltip.
WorkspaceDotView
A transparent overlay on top of the video surface:
- Drop the 4 points (reference ×2, ball ×2).
- Pinch-zoom & pan for pixel-perfect placement.
- Renders the live speed as you drag.
Everything that touches a frame is custom-drawn — full control over hit-testing, zoom and precision.
Same model, native frameworks
Playback
AVPlayer + AVPlayerLayer. Frame-accurate seeking, with defensive clamping — high-fps AVPlayer can park one frame before item.duration.
Capture
AVCaptureSession. applyBestFormat scans device.formats, picks the highest fps in a resolution budget, then pins activeVideoMin/MaxFrameDuration to lock 240 fps.
Import & thumbs
PHPickerViewController to import gallery clips, AVAssetImageGenerator for poster frames.
Identical math
hypot() for both distances, then distanceMeters * 3600 / deltaMs — the exact same formula as Android.
Why a single HTML5 page?
Two native apps means two store links. I want one URL to rule them all.
- One link for QR codes, bio, ads & word of mouth.
- Smart App Banners via
apple-itunes-app&google-play-appmeta. - Crawlable by Google — app store pages aren't.
Built to be found
Structured data — JSON-LD @graph
- WebSite + Organization — identity & brand.
- MobileApplication — category, free
Offer, both store URLs. - FAQPage — 6 Q&As eligible for rich snippets.
On-page
hreflangen / fr +canonical, Open Graph cards.sitemap.xml,robots.txt, semantic headings.
"@type": "MobileApplication",
"operatingSystem": "iOS, Android",
"offers": {
"@type": "Offer",
"price": "0"
},
"aggregateRating": {
"ratingValue": "4.8",
"reviewCount": "…"
}Test, index, and feed the loop
Rich Results Test
Validate the structured data & preview the FAQ snippet before shipping.
search.google.com/test/rich-results
Search Console
Submit the sitemap, request indexing, watch the real queries that bring traffic.
search.google.com/search-console
Ratings & reviews
A release script pulls store ratings and injects a rating strip + AggregateRating — social proof that also feeds SEO.
Keep the FAQ alive
Answer real user questions on the page. Better UX and more rich-snippet surface area.
App vs. Roland-Garros
The ultimate sanity check: point the app at a serve where the official Roland-Garros tracking system already shows the speed on screen.
- Filmed a serve courtside in portrait — the venue's system flashes the ground truth speed.
- Ran the same clip through Tennis Radar: mark the reference, tap the ball on two frames.
- The two numbers landed in the same ballpark — within a few percent of the pro hardware.
- One hand-held clip, not a lab — but enough to trust that
distance / timeholds up against millions of euros of cameras.
What I'd take away
Simple beat clever
AI was the obvious path and the wrong one. distance / time is trustworthy, fast and explainable.
Debuggable > magic
Every input is something the user pointed at. When a number looks off, you can see exactly why.
One idea, three platforms
Shared architecture on Android & iOS, native frameworks under the hood, and a web page to glue it together.
Questions ?
Happy to dig into the maths, the players, the custom views, or the SEO.
Thanks for watching 🎾