Tennis Radar Technical deep-dive

Tennis Radar

Turning a phone video into a radar gun.
The idea, the dead-ends, and how it actually works.

Android iOS Web Jonathan Mercandalli

← → to navigate  ·  ⌘⏎ fullscreen  ·  S notes  ·  T timer

The idea

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.

Prior art

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.

Iteration 1 of 4

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.
Iteration 2 of 4

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.
Iteration 3 of 4

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.
Iteration 4 of 4 — the insight

Stop guessing.
Use what the phone already knows.

speed = distance ÷ time
⏱️

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.

Live demo

Let's run it on Android

1

Record or import a slow-mo clip

→
2

Trim to the rally

→
3

Mark the reference (top & bottom)

→
4

Tap the ball on 2 frames

→
5

Read the speed 🎯

Live screen mirror — fall back to the clip below if needed.

The model

Three measurements, one division

video frameL = 0.685 m( = refPx pixels )A · t₁B · t₂d pixels
Reference — a real object of known length L, measured in pixels refPx. Gives the scale metresPerPixel = L / refPx.
Ball A → B — same ball, two frames. Pixel gap d × scale = real distance in metres.
Time — Δt = (t₂ − t₁) / slowMotionFactor, where frame spacing comes from the recorded fps.
Speed = distance ÷ time. That's it.
The computation · Kotlin

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
Detail that matters

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 / fps ms — 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)
Android · playback

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 ExoPlayer instance, reused across screens.
fun create(context: Context): ExoPlayer {
  return ExoPlayer.Builder(context)
    .build().apply {
      repeatMode = REPEAT_MODE_ONE
      setSeekParameters(
        SeekParameters.EXACT
      )
    }
}
Android · capture

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()
  )
Android · custom UI

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.

iOS · AVFoundation

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.

Cross-platform glue

Why a single HTML5 page?

Two native apps means two store links. I want one URL to rule them all.

mercandalli.com/tennis
→ iOS  ·  App Store
→ Android  ·  Google Play
→ Desktop / other  ·  the web page itself
The website · SEO

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

  • hreflang en / 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": "…"
}
Using Google

Test, index, and feed the loop

✅

Rich Results Test

Validate the structured data & preview the FAQ snippet before shipping.

🔎

Search Console

Submit the sitemap, request indexing, watch the real queries that bring traffic.

⭐

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.

Does it actually work?

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 / time holds up against millions of euros of cameras.
Wrapping up

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.

Tennis Radar

Questions ?

Happy to dig into the maths, the players, the custom views, or the SEO.

App Store · id 6762805167 Google Play · …tennis_ball_speed mercandalli.com/tennis

Thanks for watching 🎾