Volleyball Serve Speed Lab
Build an OpenCV tool to estimate volleyball serve speed from video frames.
Introduction
30 Second Summary
A fast volleyball serve can cross part of the court in only a few video frames. Pausing by eye makes the exact crossing moments difficult to judge.
In this project, you will build a local volleyball speed analyzer with OpenCV. It turns two manually marked crossing frames into a clearly labelled average-speed estimate.
What You'll Build
Your volleyball clip will become an interactive frame-by-frame lab with clicked court gates plus a speed estimate drawn over the action.
By the end of this project, you'll have:
- A frame-by-frame video viewer for serve.mp4 with reported FPS plus frame count shown over the footage.
- An interactive two-gate workflow that places court guides from your clicks. Keyboard controls capture both crossing frames.
- An average-speed estimate over a 3 m court segment in m/s, km/h, and mph. A permanent footage-quality warning keeps the result framed as an estimate.
- Secret Mission: Add a worst-case speed range that allows each crossing to be one frame early or late.
Are there any prerequisites?
Basic Python knowledge covering variables, functions, conditionals, and dictionaries is enough. You need internet access once to install OpenCV plus download the practice clip.
Before We Start
First, lock in what this lab measures and how you will describe the result. You are estimating average ball speed across a visible 3 m court segment because footage timing, editing, perspective, and manual frame marking can introduce error.
Set Up the Lab and Footage
Your speed estimate depends on a repeatable local workspace. A clean environment keeps this project's package isolated from other Python projects.
You will prepare Visual Studio Code with a dedicated Python environment. You will install OpenCV 5.0.0.93 inside it.
You will also save a Pexels clip locally as practice footage.
In this step, get ready to:
- Prepare the volleyball-speed-lab workspace with the Microsoft Python extension.
- Verify a compatible Python version.
- Create the isolated OpenCV environment plus the practice files.
Create the workspace and verify Python
A Visual Studio Code workspace keeps every project file in one named folder. The Python extension connects that workspace to your interpreter.
- Press the Windows key to open the Windows search bar.
- Enter Visual Studio Code in the search bar.
- Press Enter to open Visual Studio Code.
- Select File from the top menu.
- Select Open Folder.
- Use the folder picker to create volleyball-speed-lab on your Desktop.
- Select volleyball-speed-lab as the workspace folder.
You'll see the empty volleyball-speed-lab workspace in the Explorer sidebar.
- Select the extension manager from the left sidebar.
- Search for ms-python.python.
- Choose the Python extension published by Microsoft.
- Select the install control on the extension page.
The extension page shows that the Microsoft Python extension is installed.
OpenCV 5.0.0.93 supports Python 3.7 or newer. Checking first prevents an incompatible interpreter from blocking environment creation.
- Open Visual Studio Code's integrated terminal from the top menu.
- Check your installed Python version by running this command:
py -3 --version
What does this command do?
The py launcher asks Windows for a Python 3 interpreter. The output identifies the version available to this project.
✔️ I see a supported Python version
You're set. Python 3.7 or newer is ready for the OpenCV environment.
ⓧ I see an older Python version
Your installed version is below 3.7. Install a current supported release before creating the environment.
- Visit the official Python releases for Windows page.
- Follow the official Windows installer to install a current supported Python release.
- Return to the integrated terminal in Visual Studio Code.
- Recheck the installed version by running this command:
py -3 --version
What does this command confirm?
The version check confirms which Python 3 interpreter the Windows launcher can access after installation.
Continue when the output reports Python 3.7 or newer.
ⓧ Command not found
Windows cannot currently find the Python launcher. Install a current supported Python release before continuing.
- Visit the official Python releases for Windows page.
- Follow the official Windows installer to install a current supported Python release.
- Return to the integrated terminal in Visual Studio Code.
- Check the new installation by running this command:
py -3 --version
What does this command confirm?
The command confirms that the Windows Python launcher can now reach your installed Python 3 interpreter.
Continue when the output reports Python 3.7 or newer.
Create the OpenCV environment
A virtual environment gives this project its own package installation. The requirements.txt file records the exact OpenCV version that belongs inside it.
- Use the file control in the Explorer sidebar to create requirements.txt inside volleyball-speed-lab.
- Paste this dependency pin into requirements.txt:
opencv-python==5.0.0.93
What does this dependency pin do?
The requirement tells the environment workflow to install the desktop OpenCV package at version 5.0.0.93. The desktop package includes the window features used later in the project.
- Save requirements.txt.
- Confirm that the Explorer sidebar lists requirements.txt.
✔️ Awesome, I've got everything!
Great. Your dependency file is saved with the required OpenCV version.
ⓧ I'd like to double check the full code
opencv-python==5.0.0.93
What should match?
Your requirements.txt file contains this single dependency pin.
Visual Studio Code can create the environment from this file. It also selects the new interpreter for the workspace.
- Press Ctrl+Shift+P to open the Command Palette.
- Enter Python: Create Environment in the Command Palette.
- Select Python: Create Environment.
- Select Venv as the environment type.
- Select the Python interpreter that passed your version check.
- Select requirements.txt when Visual Studio Code asks which dependencies to install.
You'll see .venv appear inside volleyball-speed-lab. Visual Studio Code selects that environment as the workspace interpreter.
Environment missing or incomplete?
Confirm that requirements.txt was saved before you started the environment workflow. Repeat Python: Create Environment if .venv does not appear.
Make sure you select requirements.txt when prompted. That selection installs OpenCV inside the new environment.
Help me diagnose why Visual Studio Code did not create or select my .venv environment.
Add the readiness check and practice footage
A short import check proves that the selected environment can load OpenCV. This catches setup problems before video analysis code is added.
- Use the file control in the Explorer sidebar to create serve_speed_lab.py inside volleyball-speed-lab.
- Paste this readiness check into serve_speed_lab.py:
import cv2 as cv
print("OpenCV is ready")
What does this code do?
- The import cv2 as cv line loads OpenCV under the shorter cv name.
- The print() call produces a clear success message after the import completes.
- Save serve_speed_lab.py.
- Keep serve_speed_lab.py active in the editor.
- Select Run Python File in Terminal from the editor controls.
You'll see OpenCV is ready in the terminal. This proves that the selected environment can import OpenCV.
Import check not working?
Confirm that Visual Studio Code has selected the interpreter inside .venv. Recreate the environment if the terminal reports that the OpenCV module is missing.
Check that requirements.txt contains the exact package pin shown above.
Help me fix the OpenCV import check in my selected Visual Studio Code environment.
✔️ Awesome, I've got everything!
Your readiness script is saved. The selected environment can load OpenCV.
ⓧ I'd like to double check the full code
import cv2 as cv
print("OpenCV is ready")
What should match?
Your serve_speed_lab.py file contains one OpenCV import plus one readiness message.
The local clip gives the project a repeatable video input. Its role here is to demonstrate the interface with practice footage.
- Open the Pexels clip Group of Athletes Playing Volleyball in a Covered Court in your browser.
- Use the page's download control to save a local copy inside volleyball-speed-lab.
- Rename the downloaded video to serve.mp4.
- Confirm that the Explorer sidebar lists serve.mp4 beside serve_speed_lab.py.
Why is this practice footage?
The clip gives you a legal video input for building the analyzer. Its original playback speed plus camera geometry are not guaranteed calibration data.
Treat any result from this clip as an interface demonstration unless its timing plus court dimensions are independently known.
Before you run the final check, do you expect the selected .venv environment to load OpenCV successfully?
- Keep serve_speed_lab.py active in the editor.
- Select Run Python File in Terminal from the editor controls.
You'll see OpenCV is ready in the terminal. The Explorer sidebar shows requirements.txt, serve_speed_lab.py, and serve.mp4 beside the selected .venv environment.
Your lab is ready. The environment can load OpenCV plus the workspace has a local practice clip.
Your workspace now has everything needed to decode video. Next up, you will put the first frame on screen.
Display the First Video Frame
Your selected environment can import OpenCV. That proves the package is available inside the workspace.
Now you need proof that OpenCV can decode serve.mp4 through the Windows display backend. You will put its first frame on screen with the clip's reported timing details.
In this step, get ready to:
- Define the frame-seeking helper.
- Build the width-limited display helper.
- Display frame 1 with its reported FPS and frame count.
Define the frame-seeking helper
Video files are decoded as a sequence of frames. A reusable reader lets every later control request one exact position.
- In serve_speed_lab.py, locate print("OpenCV is ready").
- Paste this code between import cv2 as cv and the existing print line:
VIDEO_PATH = "serve.mp4"
MAX_DISPLAY_WIDTH = 1200
WINDOW_NAME = "Volleyball Serve Speed Lab"
def read_frame(capture, frame_index):
capture.set(cv.CAP_PROP_POS_FRAMES, frame_index)
ok, frame = capture.read()
if not ok:
return None
return frame
What does this code do?
- VIDEO_PATH keeps the practice clip filename in one place.
- MAX_DISPLAY_WIDTH sets the largest display width to 1200 pixels.
- WINDOW_NAME gives the video window a consistent title.
- read_frame() seeks with cv.CAP_PROP_POS_FRAMES before decoding the requested frame.
- None tells the caller that decoding failed.
- Save serve_speed_lab.py.
- Select Run Python File in Terminal from the editor toolbar.
The terminal still prints OpenCV is ready. That confirms Python loaded the new constants and helper successfully.
Helper code not loading?
- Check that read_frame() starts at the left edge of the file.
- Check that every line inside read_frame() uses four spaces of indentation.
Help me find the syntax or indentation problem in my frame reader.
Wide footage can extend beyond a laptop screen. The next helper shrinks oversized frames while preserving their proportions.
- Paste this helper below read_frame() and above the existing print line:
def fit_to_window(frame):
height, width = frame.shape[:2]
if width <= MAX_DISPLAY_WIDTH:
return frame
scale = MAX_DISPLAY_WIDTH / width
display_height = int(height * scale)
return cv.resize(
frame,
(MAX_DISPLAY_WIDTH, display_height),
interpolation=cv.INTER_AREA,
)
How does the resizing work?
- frame.shape[:2] reads the frame's height and width.
- MAX_DISPLAY_WIDTH leaves smaller footage at its original size.
- scale calculates the matching height for a 1200-pixel width.
- cv.INTER_AREA gives OpenCV an area-based method for shrinking the image.
- Save serve_speed_lab.py.
- Select Run Python File in Terminal from the editor toolbar.
You will see OpenCV is ready again. The new resizing helper now loads without stopping the script.
Resizing helper not loading?
- Check that the closing parenthesis for cv.resize() lines up with its return statement.
- Check that MAX_DISPLAY_WIDTH matches the constant near the top of the file.
Help me debug the fit_to_window helper.
Open and validate the clip
A video capture object connects the local clip to OpenCV's decoder. The open check turns a missing or unreadable file into a useful message.
- In serve_speed_lab.py, select the existing print("OpenCV is ready") line.
- Replace that line with this runnable first-frame workflow:
def main():
capture = cv.VideoCapture(VIDEO_PATH)
if not capture.isOpened():
raise SystemExit(
f"Could not open {VIDEO_PATH}. Put the video beside this script."
)
frame = read_frame(capture, 0)
if frame is None:
capture.release()
raise SystemExit("OpenCV could not decode the first video frame.")
display = fit_to_window(frame.copy())
cv.imshow(WINDOW_NAME, display)
cv.waitKey(0)
capture.release()
cv.destroyAllWindows()
if __name__ == "__main__":
main()
What does this workflow do?
- cv.VideoCapture(VIDEO_PATH) opens serve.mp4 as the video source.
- capture.isOpened() checks whether OpenCV initialized that source.
- read_frame(capture, 0) requests the first zero-indexed frame.
- fit_to_window() prepares a screen-sized copy.
- cv.waitKey(0) keeps the window responsive until you press a key.
- capture.release() closes the video source after the window check.
- Save serve_speed_lab.py.
Before you run it, do you expect the first decoded frame to reach a desktop window?
- Select Run Python File in Terminal from the editor toolbar.
You will see the clip's first frame in a window titled Volleyball Serve Speed Lab.
- Press any key while the video window is focused to close it.
That first frame is your proof that the clip path, decoder, resizing helper, and Windows display backend work together.
Video window not opening?
- Confirm that serve.mp4 sits beside serve_speed_lab.py in the Explorer.
- Confirm that the selected interpreter points to the .venv environment from earlier.
- Run the script again after closing any older video window.
Help me diagnose why my OpenCV video window does not open.
Label the first frame with metadata
Video timing depends on the FPS and frame count reported by the capture backend. Validating both values prevents the app from continuing with unusable timing data.
- In main(), find frame = read_frame(capture, 0).
- Paste this metadata check directly above that line:
fps = float(capture.get(cv.CAP_PROP_FPS))
total_frames = int(capture.get(cv.CAP_PROP_FRAME_COUNT))
if fps <= 0 or total_frames <= 0:
capture.release()
raise SystemExit("The video did not report usable FPS or frame-count metadata.")
Why validate the reported metadata?
- cv.CAP_PROP_FPS retrieves the frame rate reported for the clip.
- cv.CAP_PROP_FRAME_COUNT retrieves the reported number of frames.
- The guard stops the app when either reported value is zero or negative.
- The word Reported keeps the display honest because backend metadata can vary.
- Save serve_speed_lab.py.
- Select Run Python File in Terminal from the editor toolbar.
The first frame opens again. That confirms the clip reported a positive FPS and frame count before display.
- Press any key while the video window is focused to close it.
Video metadata rejected?
- Confirm that serve.mp4 is the downloaded video file instead of a web-page shortcut.
- Replace the practice clip with a local video that reports usable timing metadata.
Help me understand why my video reports unusable FPS or frame-count metadata.
The validated values become useful when they are visible beside the frame. A text overlay makes the first display checkpoint easy to verify.
- In main(), find display = fit_to_window(frame.copy()).
- Paste this overlay code directly below that line:
cv.putText(
display,
f"Frame 1/{total_frames} | Reported FPS: {fps:.3f}",
(20, 30),
cv.FONT_HERSHEY_SIMPLEX,
0.62,
(255, 255, 255),
1,
cv.LINE_AA,
)
How does the label work?
- The formatted string combines frame 1 with the reported frame count.
- fps:.3f displays the reported FPS to three decimal places.
- (20, 30) positions the text near the top-left corner.
- cv.FONT_HERSHEY_SIMPLEX selects a readable built-in font.
- cv.LINE_AA smooths the text edges.
- Save serve_speed_lab.py.
Before you run the finished check, where do you expect the reported metadata to appear on the frame?
- Select Run Python File in Terminal from the editor toolbar.
You will see frame 1 in the Volleyball Serve Speed Lab window. A top-left label starts with Frame 1/ and includes Reported FPS:.
- Press any key while the video window is focused to close it.
You have the first visual checkpoint working. Your lab now opens the practice clip and exposes the timing details needed for frame-by-frame analysis.
Metadata label missing?
- Check that cv.putText() appears before cv.imshow() inside main().
- Check that the label is drawn on display.
- Check that fps and total_frames use the same spelling throughout main().
Help me find why the metadata text is missing from my OpenCV window.
✔️ Awesome, I've got everything!
Great. Double-check that serve_speed_lab.py is saved before you continue.
ⓧ I'd like to double check the full code
import cv2 as cv
VIDEO_PATH = "serve.mp4"
MAX_DISPLAY_WIDTH = 1200
WINDOW_NAME = "Volleyball Serve Speed Lab"
def read_frame(capture, frame_index):
capture.set(cv.CAP_PROP_POS_FRAMES, frame_index)
ok, frame = capture.read()
if not ok:
return None
return frame
def fit_to_window(frame):
height, width = frame.shape[:2]
if width <= MAX_DISPLAY_WIDTH:
return frame
scale = MAX_DISPLAY_WIDTH / width
display_height = int(height * scale)
return cv.resize(
frame,
(MAX_DISPLAY_WIDTH, display_height),
interpolation=cv.INTER_AREA,
)
def main():
capture = cv.VideoCapture(VIDEO_PATH)
if not capture.isOpened():
raise SystemExit(
f"Could not open {VIDEO_PATH}. Put the video beside this script."
)
fps = float(capture.get(cv.CAP_PROP_FPS))
total_frames = int(capture.get(cv.CAP_PROP_FRAME_COUNT))
if fps <= 0 or total_frames <= 0:
capture.release()
raise SystemExit("The video did not report usable FPS or frame-count metadata.")
frame = read_frame(capture, 0)
if frame is None:
capture.release()
raise SystemExit("OpenCV could not decode the first video frame.")
display = fit_to_window(frame.copy())
cv.putText(
display,
f"Frame 1/{total_frames} | Reported FPS: {fps:.3f}",
(20, 30),
cv.FONT_HERSHEY_SIMPLEX,
0.62,
(255, 255, 255),
1,
cv.LINE_AA,
)
cv.imshow(WINDOW_NAME, display)
cv.waitKey(0)
capture.release()
cv.destroyAllWindows()
if __name__ == "__main__":
main()
Your first decoded frame is live. Next up, you will turn this static window into an interactive frame-by-frame measurement tool.
Mark the Two Gate Crossings
Your first decoded frame proves the OpenCV video path works. The Windows display backend works too.
Frame timing can measure how long the ball takes to travel between two visible points. This step tests how far that duration can take you toward a physical speed measurement.
In this step, get ready to:
- Place two visual gates in the ball's direction of travel.
- Step through the clip one frame at a time.
- Mark both crossing frames to reveal the elapsed video time.
Place the gate guides
A mouse callback turns each click inside the video window into an x-coordinate. The first coordinate becomes the start gate.
The interactive overlay redraws the resized frame after every click. First, update the resize helper to match the compact version used by the overlay.
- Replace the existing fit_to_window function in serve_speed_lab.py with this version:
def fit_to_window(frame):
height, width = frame.shape[:2]
if width <= MAX_DISPLAY_WIDTH:
return frame
scale = MAX_DISPLAY_WIDTH / width
return cv.resize(frame, (MAX_DISPLAY_WIDTH, int(height * scale)), interpolation=cv.INTER_AREA)
What Does This Helper Do?
The helper keeps frames at or below MAX_DISPLAY_WIDTH. Wider frames are scaled down while preserving their proportions.
- Save serve_speed_lab.py.
- Select Run Python File in Terminal in the editor toolbar.
You'll see frame 1 open at a width that fits your display. This confirms the updated helper still prepares the frame correctly.
Does the Frame Look Stretched?
Check that the new height uses int(height * scale). Keep MAX_DISPLAY_WIDTH as the first resize dimension.
Ask for help with the resize calculation: Why does my resized OpenCV frame look stretched?
The callback stores click positions in shared state. It also updates a short message that guides the next click.
- Add on_mouse below fit_to_window in serve_speed_lab.py by copying this function:
def on_mouse(event, x, y, flags, state):
if event != cv.EVENT_LBUTTONDOWN:
return
if state["gate_start_x"] is None:
state["gate_start_x"] = x
state["message"] = "Start gate set. Click the second gate."
elif state["gate_end_x"] is None:
state["gate_end_x"] = x
state["message"] = "Both gates set. Mark crossing frames with S and E."
How Does Click Order Work?
- The event check ignores mouse actions that are not left-button clicks.
- The first accepted click fills gate_start_x.
- The second accepted click fills gate_end_x.
Overlay text needs a dark outline so it remains readable over light court markings. A small helper draws each label twice to create that contrast.
- Add add_text immediately below on_mouse by copying this function:
def add_text(image, text, y, color=(255, 255, 255)):
cv.putText(image, text, (20, y), cv.FONT_HERSHEY_SIMPLEX, 0.62, (0, 0, 0), 4, cv.LINE_AA)
cv.putText(image, text, (20, y), cv.FONT_HERSHEY_SIMPLEX, 0.62, color, 1, cv.LINE_AA)
Why Draw Each Label Twice?
The first text call creates a thick black outline. The second call places the requested colour over that outline.
- Save serve_speed_lab.py.
- Select Run Python File in Terminal in the editor toolbar.
You'll see the first frame open successfully. That result proves both new helper functions parse before you connect them to the window.
Seeing a Syntax Error?
Check the indentation inside on_mouse. Confirm that every state key uses matching quotation marks.
Get help comparing the helpers: Help me find the syntax error in my on_mouse or add_text function.
The first overlay version draws each stored gate as a vertical guide. A yellow line marks the start gate.
- Add draw_overlay below add_text by copying this first version:
def draw_overlay(frame, state, fps, total_frames):
display = fit_to_window(frame.copy())
height, width = display.shape[:2]
if state["gate_start_x"] is not None:
x = min(state["gate_start_x"], width - 1)
cv.line(display, (x, 0), (x, height - 1), (0, 255, 255), 2, cv.LINE_AA)
cv.putText(display, "START", (max(5, x - 35), 28), cv.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 255), 2, cv.LINE_AA)
if state["gate_end_x"] is not None:
x = min(state["gate_end_x"], width - 1)
cv.line(display, (x, 0), (x, height - 1), (0, 255, 0), 2, cv.LINE_AA)
cv.putText(display, "END", (max(5, x - 25), 52), cv.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 0), 2, cv.LINE_AA)
return display
How Are the Guides Drawn?
- Each gate appears only after its state value contains an x-coordinate.
- The start gate uses yellow pixels with a START label.
- The end gate uses green pixels with an END label.
- Save serve_speed_lab.py.
- Select Run Python File in Terminal in the editor toolbar.
You'll see the first frame open again. This confirms the overlay function is ready to receive shared state.
The shared state dictionary gives the mouse callback and keyboard loop one source of truth. Every interaction updates this same dictionary.
- Replace frame = read_frame(capture, 0) inside main with these lines:
state = {"frame_index": 0, "gate_start_x": None, "gate_end_x": None, "start_frame": None, "end_frame": None, "message": "Click the first court gate in the ball's travel direction."}
frame = read_frame(capture, state["frame_index"])
What Does the State Track?
- The frame_index value tracks the frame currently displayed.
- The gate values store the two click positions.
- The crossing values store the two marked frame numbers.
- The message value gives immediate feedback after each interaction.
- Replace the existing display calls from display = fit_to_window(frame.copy()) through cv.waitKey(0) with this window loop:
cv.namedWindow(WINDOW_NAME, cv.WINDOW_AUTOSIZE)
cv.setMouseCallback(WINDOW_NAME, on_mouse, state)
while True:
cv.imshow(WINDOW_NAME, draw_overlay(frame, state, fps, total_frames))
key = cv.waitKey(20)
if key < 0:
continue
if key == ord("q"):
break
Why Use a Window Loop?
The loop redraws the frame while OpenCV processes mouse events. The registered callback receives the same state dictionary on every click.
The Q key exits the loop so the existing cleanup calls can close the video safely.
- Save serve_speed_lab.py.
- Select Run Python File in Terminal in the editor toolbar.
- Click the first gate position in the ball's direction of travel.
- Click the second gate position in the ball's direction of travel.
Good progress. You'll see a yellow start guide plus a green end guide over the video.
Do the Gate Lines Stay Hidden?
Confirm that cv.setMouseCallback uses WINDOW_NAME. Check that it receives state as its final argument.
Ask for help tracing the clicks: Why does my OpenCV mouse callback run without showing gate lines?
Step through the video
Frame seeking moves the capture to a specific numbered frame. Stepping one index at a time lets you inspect the exact moment the ball crosses each gate.
The overlay now needs controls plus the current frame number. It also needs space for crossing details that appear after you mark them.
- Replace the final return display in draw_overlay with this overlay section:
lines = [
f"Frame {state['frame_index'] + 1}/{total_frames} | Reported FPS: {fps:.3f}",
"A/D: step | S: start | E: end | R: reset marks | G: reset gates | Q: quit",
]
if state["gate_start_x"] is None:
lines.append("Click the first court gate in the ball's travel direction.")
elif state["gate_end_x"] is None:
lines.append("Click the second court gate in the ball's travel direction.")
else:
lines.append("Gates ready. Step to each crossing and press S, then E.")
if state["start_frame"] is not None:
lines.append(f"Start crossing frame: {state['start_frame'] + 1}")
if state["end_frame"] is not None:
lines.append(f"End crossing frame: {state['end_frame'] + 1}")
lines.append(f"Time: {(state['end_frame'] - state['start_frame']) / fps:.3f} s")
if state["message"]:
lines.append(state["message"])
for index, text in enumerate(lines):
add_text(display, text, 30 + index * 28)
return display
What Will the Overlay Report?
- The first line shows the current frame position plus the video's reported FPS.
- The controls line keeps every keyboard action visible.
- The crossing lines appear only after their frame values have been marked.
- The time calculation divides the marked frame span by fps.
The keyboard loop uses A for the previous frame. It uses D for the next frame.
- Add this stepping logic after the Q key check inside the window loop:
next_index = max(0, state["frame_index"] - 1) if key == ord("a") else min(total_frames - 1, state["frame_index"] + 1) if key == ord("d") else state["frame_index"]
if next_index != state["frame_index"]:
next_frame = read_frame(capture, next_index)
if next_frame is not None:
state["frame_index"] = next_index
frame = next_frame
state["message"] = ""
How Does Stepping Stay Safe?
The bounds keep next_index between the first frame and the final frame. The displayed frame changes only after read_frame returns a decoded image.
- Save serve_speed_lab.py.
- Select Run Python File in Terminal in the editor toolbar.
- Press the D key once.
- Press the A key once.
You'll see the frame counter increase after D. It returns to the previous number after A.
Does the Frame Counter Stay Still?
Click inside the OpenCV window before pressing a key. The video window needs keyboard focus.
Check that the stepping block is indented inside while True. Ask for help if the frame still does not move: Why do my A and D keys not step through the OpenCV video?
Mark both crossing frames
The first crossing frame starts the timer. A later crossing frame completes the elapsed-time measurement.
- Add this marking logic after the frame-stepping block inside the window loop:
if key == ord("s"):
if state["gate_start_x"] is None or state["gate_end_x"] is None:
state["message"] = "Set both gates before marking the start frame."
else:
state["start_frame"] = state["frame_index"]
state["end_frame"] = None
state["message"] = "Start marked. Step forward and press E."
elif key == ord("e"):
if state["start_frame"] is None:
state["message"] = "Press S on the first crossing before pressing E."
elif state["frame_index"] <= state["start_frame"]:
state["message"] = "The end crossing must be later than the start."
else:
state["end_frame"] = state["frame_index"]
state["message"] = "Crossings marked. Seconds alone do not provide speed."
elif key == ord("r"):
state["start_frame"] = None
state["end_frame"] = None
state["message"] = "Crossing marks cleared."
elif key == ord("g"):
state["gate_start_x"] = None
state["gate_end_x"] = None
state["start_frame"] = None
state["end_frame"] = None
state["message"] = "Click the first court gate in the ball's travel direction."
What Do the Marking Keys Control?
- The S key stores the current frame as the start crossing.
- The E key accepts only a frame later than the start crossing.
- The R key clears both crossing marks.
- The G key clears both gates plus both crossing marks.
- Save serve_speed_lab.py.
Before you run the app, consider what the overlay can know after you mark the second crossing.
- Select Run Python File in Terminal in the editor toolbar.
- Click the first gate position in the ball's direction of travel.
- Click the second gate position in the ball's direction of travel.
- Press the D key until the ball reaches the first gate.
- Press the S key on the first crossing frame.
- Press the D key until the ball reaches the second gate.
- Press the E key on the second crossing frame.
You'll see both crossing frame numbers plus a positive elapsed time. No physical speed appears.
Why Does the Measurement Stop at Seconds?
The two frame numbers reveal how much video time passed between the crossings. Seconds describe duration.
A physical speed also needs a known real-world distance. The missing scale is the intended limit you have just exposed.
- Press the R key.
You'll see the crossing frame labels plus the elapsed time disappear. Both gate guides remain available for another measurement.
- Press the G key.
You'll see both gate guides disappear. The overlay asks you to click the first court gate again.
- Press the Q key.
The video window closes cleanly. Your complete interaction loop now handles placement, stepping, marking, resetting, and quitting.
✔️ Awesome, I've got everything!
Your saved serve_speed_lab.py now matches the completed gate-crossing stage.
ⓧ I'd like to double check the full code
import cv2 as cv
VIDEO_PATH = "serve.mp4"
MAX_DISPLAY_WIDTH = 1200
WINDOW_NAME = "Volleyball Serve Speed Lab"
def read_frame(capture, frame_index):
capture.set(cv.CAP_PROP_POS_FRAMES, frame_index)
ok, frame = capture.read()
if not ok:
return None
return frame
def fit_to_window(frame):
height, width = frame.shape[:2]
if width <= MAX_DISPLAY_WIDTH:
return frame
scale = MAX_DISPLAY_WIDTH / width
return cv.resize(frame, (MAX_DISPLAY_WIDTH, int(height * scale)), interpolation=cv.INTER_AREA)
def on_mouse(event, x, y, flags, state):
if event != cv.EVENT_LBUTTONDOWN:
return
if state["gate_start_x"] is None:
state["gate_start_x"] = x
state["message"] = "Start gate set. Click the second gate."
elif state["gate_end_x"] is None:
state["gate_end_x"] = x
state["message"] = "Both gates set. Mark crossing frames with S and E."
def add_text(image, text, y, color=(255, 255, 255)):
cv.putText(image, text, (20, y), cv.FONT_HERSHEY_SIMPLEX, 0.62, (0, 0, 0), 4, cv.LINE_AA)
cv.putText(image, text, (20, y), cv.FONT_HERSHEY_SIMPLEX, 0.62, color, 1, cv.LINE_AA)
def draw_overlay(frame, state, fps, total_frames):
display = fit_to_window(frame.copy())
height, width = display.shape[:2]
if state["gate_start_x"] is not None:
x = min(state["gate_start_x"], width - 1)
cv.line(display, (x, 0), (x, height - 1), (0, 255, 255), 2, cv.LINE_AA)
cv.putText(display, "START", (max(5, x - 35), 28), cv.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 255), 2, cv.LINE_AA)
if state["gate_end_x"] is not None:
x = min(state["gate_end_x"], width - 1)
cv.line(display, (x, 0), (x, height - 1), (0, 255, 0), 2, cv.LINE_AA)
cv.putText(display, "END", (max(5, x - 25), 52), cv.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 0), 2, cv.LINE_AA)
lines = [
f"Frame {state['frame_index'] + 1}/{total_frames} | Reported FPS: {fps:.3f}",
"A/D: step | S: start | E: end | R: reset marks | G: reset gates | Q: quit",
]
if state["gate_start_x"] is None:
lines.append("Click the first court gate in the ball's travel direction.")
elif state["gate_end_x"] is None:
lines.append("Click the second court gate in the ball's travel direction.")
else:
lines.append("Gates ready. Step to each crossing and press S, then E.")
if state["start_frame"] is not None:
lines.append(f"Start crossing frame: {state['start_frame'] + 1}")
if state["end_frame"] is not None:
lines.append(f"End crossing frame: {state['end_frame'] + 1}")
lines.append(f"Time: {(state['end_frame'] - state['start_frame']) / fps:.3f} s")
if state["message"]:
lines.append(state["message"])
for index, text in enumerate(lines):
add_text(display, text, 30 + index * 28)
return display
def main():
capture = cv.VideoCapture(VIDEO_PATH)
if not capture.isOpened():
raise SystemExit(f"Could not open {VIDEO_PATH}. Put the video beside this script.")
fps = float(capture.get(cv.CAP_PROP_FPS))
total_frames = int(capture.get(cv.CAP_PROP_FRAME_COUNT))
if fps <= 0 or total_frames <= 0:
capture.release()
raise SystemExit("The video did not report usable FPS or frame-count metadata.")
state = {"frame_index": 0, "gate_start_x": None, "gate_end_x": None, "start_frame": None, "end_frame": None, "message": "Click the first court gate in the ball's travel direction."}
frame = read_frame(capture, state["frame_index"])
if frame is None:
capture.release()
raise SystemExit("OpenCV could not decode the first video frame.")
cv.namedWindow(WINDOW_NAME, cv.WINDOW_AUTOSIZE)
cv.setMouseCallback(WINDOW_NAME, on_mouse, state)
while True:
cv.imshow(WINDOW_NAME, draw_overlay(frame, state, fps, total_frames))
key = cv.waitKey(20)
if key < 0:
continue
if key == ord("q"):
break
next_index = max(0, state["frame_index"] - 1) if key == ord("a") else min(total_frames - 1, state["frame_index"] + 1) if key == ord("d") else state["frame_index"]
if next_index != state["frame_index"]:
next_frame = read_frame(capture, next_index)
if next_frame is not None:
state["frame_index"] = next_index
frame = next_frame
state["message"] = ""
if key == ord("s"):
if state["gate_start_x"] is None or state["gate_end_x"] is None:
state["message"] = "Set both gates before marking the start frame."
else:
state["start_frame"] = state["frame_index"]
state["end_frame"] = None
state["message"] = "Start marked. Step forward and press E."
elif key == ord("e"):
if state["start_frame"] is None:
state["message"] = "Press S on the first crossing before pressing E."
elif state["frame_index"] <= state["start_frame"]:
state["message"] = "The end crossing must be later than the start."
else:
state["end_frame"] = state["frame_index"]
state["message"] = "Crossings marked. Seconds alone do not provide speed."
elif key == ord("r"):
state["start_frame"] = None
state["end_frame"] = None
state["message"] = "Crossing marks cleared."
elif key == ord("g"):
state["gate_start_x"] = None
state["gate_end_x"] = None
state["start_frame"] = None
state["end_frame"] = None
state["message"] = "Click the first court gate in the ball's travel direction."
capture.release()
cv.destroyAllWindows()
if __name__ == "__main__":
main()
Your gate workflow now turns two manually verified crossings into elapsed video time. Next up, you'll add the physical scale that turns that duration into an honest speed estimate.
Calculate the Speed Estimate
Your OpenCV app already records two crossing frames. It can also calculate the elapsed video time between them.
This step adds the 3 m separation from the volleyball court centre-line axis to the attack-line rear edge. Dividing that distance by the elapsed time produces an average-speed estimate.
In this step, get ready to:
- Define the 3 m court distance.
- Calculate average speed in three units.
- Display the result with an estimate warning.
Calculate speed from the 3 m crossing
Average speed needs a known physical distance. The 3 m constant gives every valid measurement the same court scale.
- In serve_speed_lab.py, find the VIDEO_PATH constant.
- Add the court distance immediately below it by copying this line:
COURT_DISTANCE_METERS = 3.0
What does this value represent?
The COURT_DISTANCE_METERS constant represents the official 3 m separation between the centre-line axis and the attack-line rear edge. It applies only when your clicked gates match those two court lines.
- In serve_speed_lab.py, find the end of fit_to_window().
- Add the measurement helper immediately below that function by copying this code:
def calculate_measurement(state, fps):
start_frame = state["start_frame"]
end_frame = state["end_frame"]
if start_frame is None or end_frame is None or end_frame <= start_frame:
return None
elapsed_seconds = (end_frame - start_frame) / fps
meters_per_second = COURT_DISTANCE_METERS / elapsed_seconds
return {
"elapsed_seconds": elapsed_seconds,
"meters_per_second": meters_per_second,
"kilometers_per_hour": meters_per_second * 3.6,
"miles_per_hour": meters_per_second / 0.44704,
}
What does this function calculate?
- The validity check returns None when either crossing is missing. It also rejects an end frame that does not come after the start frame.
- The elapsed time comes from the marked frame difference divided by the reported frame rate.
- The distance-over-time calculation produces meters_per_second.
- The returned dictionary also converts the result into kilometers_per_hour plus miles_per_hour.
- Save serve_speed_lab.py.
Before you run this version, do you expect the existing frame controls to keep working after the new helper is added?
- Select Run Python File in Terminal from the top-right editor toolbar.
Good progress. Your existing gates plus elapsed-time overlay still work with the new helper loaded.
Window fails after adding the helper?
Check that calculate_measurement() starts at the left edge of the file. Confirm that each line inside the function uses consistent indentation.
Compare every dictionary key with the code above. Help me fix the new measurement function.
Display the converted result
The helper now returns everything the overlay needs. The next change replaces the elapsed-time-only output with one measurement line.
- In draw_overlay(), find the block that adds the end crossing frame.
- Replace the old elapsed-time line plus the remaining lines through return display with this code:
measurement = calculate_measurement(state, fps)
if measurement is not None:
lines.append("Time: " f"{measurement['elapsed_seconds']:.3f} s | " f"{measurement['meters_per_second']:.2f} m/s | " f"{measurement['kilometers_per_hour']:.1f} km/h | " f"{measurement['miles_per_hour']:.1f} mph")
if state["message"]:
lines.append(state["message"])
for index, text in enumerate(lines):
add_text(display, text, 30 + index * 28)
add_text(display, "Estimate only: use unedited footage and a near side-on view.", height - 20, (0, 220, 255))
return display
How does the overlay use the result?
- The overlay calls calculate_measurement() on every displayed frame.
- A valid result shows elapsed seconds plus average speed in m/s.
- Multiplying by 3.6 converts m/s into km/h.
- Dividing by 0.44704 converts m/s into mph.
- The permanent warning keeps the result framed as a video-based estimate.
- In main(), find the status assignment that runs after a valid end crossing.
- Replace that assignment with this line:
state["message"] = "Measurement ready. Press R to try again."
Why update the status message?
The earlier message described the elapsed-time limitation from the previous step. This message now confirms that a complete distance-over-time measurement is ready.
✔️ Awesome, I've got everything!
- Save serve_speed_lab.py before the final check.
ⓧ I'd like to double check the full code
- Compare serve_speed_lab.py with this completed file:
import cv2 as cv
VIDEO_PATH = "serve.mp4"
COURT_DISTANCE_METERS = 3.0
MAX_DISPLAY_WIDTH = 1200
WINDOW_NAME = "Volleyball Serve Speed Lab"
def read_frame(capture, frame_index):
capture.set(cv.CAP_PROP_POS_FRAMES, frame_index)
ok, frame = capture.read()
if not ok:
return None
return frame
def fit_to_window(frame):
height, width = frame.shape[:2]
if width <= MAX_DISPLAY_WIDTH:
return frame
scale = MAX_DISPLAY_WIDTH / width
display_height = int(height * scale)
return cv.resize(
frame,
(MAX_DISPLAY_WIDTH, display_height),
interpolation=cv.INTER_AREA,
)
def calculate_measurement(state, fps):
start_frame = state["start_frame"]
end_frame = state["end_frame"]
if start_frame is None or end_frame is None or end_frame <= start_frame:
return None
elapsed_seconds = (end_frame - start_frame) / fps
meters_per_second = COURT_DISTANCE_METERS / elapsed_seconds
return {
"elapsed_seconds": elapsed_seconds,
"meters_per_second": meters_per_second,
"kilometers_per_hour": meters_per_second * 3.6,
"miles_per_hour": meters_per_second / 0.44704,
}
def on_mouse(event, x, y, flags, state):
if event != cv.EVENT_LBUTTONDOWN:
return
if state["gate_start_x"] is None:
state["gate_start_x"] = x
state["message"] = "Start gate set. Click the second gate."
elif state["gate_end_x"] is None:
state["gate_end_x"] = x
state["message"] = "Both gates set. Mark crossing frames with S and E."
def add_text(image, text, y, color=(255, 255, 255)):
cv.putText(image, text, (20, y), cv.FONT_HERSHEY_SIMPLEX, 0.62, (0, 0, 0), 4, cv.LINE_AA)
cv.putText(image, text, (20, y), cv.FONT_HERSHEY_SIMPLEX, 0.62, color, 1, cv.LINE_AA)
def draw_overlay(frame, state, fps, total_frames):
display = fit_to_window(frame.copy())
height, width = display.shape[:2]
if state["gate_start_x"] is not None:
x = min(state["gate_start_x"], width - 1)
cv.line(display, (x, 0), (x, height - 1), (0, 255, 255), 2, cv.LINE_AA)
cv.putText(display, "START", (max(5, x - 35), 28), cv.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 255), 2, cv.LINE_AA)
if state["gate_end_x"] is not None:
x = min(state["gate_end_x"], width - 1)
cv.line(display, (x, 0), (x, height - 1), (0, 255, 0), 2, cv.LINE_AA)
cv.putText(display, "END", (max(5, x - 25), 52), cv.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 0), 2, cv.LINE_AA)
lines = [f"Frame {state['frame_index'] + 1}/{total_frames} | Reported FPS: {fps:.3f}", "A/D: step | S: start | E: end | R: reset marks | G: reset gates | Q: quit"]
if state["gate_start_x"] is None:
lines.append("Click the first court gate in the ball's travel direction.")
elif state["gate_end_x"] is None:
lines.append("Click the second court gate in the ball's travel direction.")
else:
lines.append("Gates ready. Step to each crossing and press S, then E.")
if state["start_frame"] is not None:
lines.append(f"Start crossing frame: {state['start_frame'] + 1}")
if state["end_frame"] is not None:
lines.append(f"End crossing frame: {state['end_frame'] + 1}")
measurement = calculate_measurement(state, fps)
if measurement is not None:
lines.append("Time: " f"{measurement['elapsed_seconds']:.3f} s | " f"{measurement['meters_per_second']:.2f} m/s | " f"{measurement['kilometers_per_hour']:.1f} km/h | " f"{measurement['miles_per_hour']:.1f} mph")
if state["message"]:
lines.append(state["message"])
for index, text in enumerate(lines):
add_text(display, text, 30 + index * 28)
add_text(display, "Estimate only: use unedited footage and a near side-on view.", height - 20, (0, 220, 255))
return display
def main():
capture = cv.VideoCapture(VIDEO_PATH)
if not capture.isOpened():
raise SystemExit(f"Could not open {VIDEO_PATH}. Put the video beside this script.")
fps = float(capture.get(cv.CAP_PROP_FPS))
total_frames = int(capture.get(cv.CAP_PROP_FRAME_COUNT))
if fps <= 0 or total_frames <= 0:
capture.release()
raise SystemExit("The video did not report usable FPS or frame-count metadata.")
state = {"frame_index": 0, "gate_start_x": None, "gate_end_x": None, "start_frame": None, "end_frame": None, "message": "Click the first court gate in the ball's travel direction."}
frame = read_frame(capture, state["frame_index"])
if frame is None:
capture.release()
raise SystemExit("OpenCV could not decode the first video frame.")
cv.namedWindow(WINDOW_NAME, cv.WINDOW_AUTOSIZE)
cv.setMouseCallback(WINDOW_NAME, on_mouse, state)
while True:
cv.imshow(WINDOW_NAME, draw_overlay(frame, state, fps, total_frames))
key = cv.waitKey(20)
if key < 0:
continue
if key == ord("q"):
break
next_index = max(0, state["frame_index"] - 1) if key == ord("a") else min(total_frames - 1, state["frame_index"] + 1) if key == ord("d") else state["frame_index"]
if next_index != state["frame_index"]:
next_frame = read_frame(capture, next_index)
if next_frame is not None:
state["frame_index"] = next_index
frame = next_frame
state["message"] = ""
if key == ord("s"):
if state["gate_start_x"] is None or state["gate_end_x"] is None:
state["message"] = "Set both gates before marking the start frame."
else:
state["start_frame"] = state["frame_index"]
state["end_frame"] = None
state["message"] = "Start marked. Step forward and press E."
elif key == ord("e"):
if state["start_frame"] is None:
state["message"] = "Press S on the first crossing before pressing E."
elif state["frame_index"] <= state["start_frame"]:
state["message"] = "The end crossing must be later than the start."
else:
state["end_frame"] = state["frame_index"]
state["message"] = "Measurement ready. Press R to try again."
elif key == ord("r"):
state["start_frame"] = None
state["end_frame"] = None
state["message"] = "Crossing marks cleared."
elif key == ord("g"):
state["gate_start_x"] = None
state["gate_end_x"] = None
state["start_frame"] = None
state["end_frame"] = None
state["message"] = "Click the first court gate in the ball's travel direction."
capture.release()
cv.destroyAllWindows()
if __name__ == "__main__":
main()
What should you compare?
This reference shows the exact completed state of serve_speed_lab.py. Check the placement of the distance constant plus the measurement helper first.
Verify the repeatable measurement
The 3 m scale has strict physical assumptions. A result from arbitrary landmarks does not represent a real 3 m crossing.
When is the 3 m scale valid?
- Use the first gate for the volleyball court centre-line axis.
- Use the second gate for the attack-line rear edge.
- Confirm that the ball crosses both gates in the same direction.
- Treat edited footage or slow-motion footage as practice input.
- Keep the camera near a side-on view to reduce perspective distortion.
The visible Estimate only: use unedited footage and a near side-on view. warning keeps these limits attached to every result.
Before you run the final check, what should appear once both crossing frames are valid?
- Save serve_speed_lab.py.
- Select Run Python File in Terminal from the top-right editor toolbar.
- Click the centre-line axis to place the start gate.
- Click the attack-line rear edge to place the end gate.
- Step to the first ball crossing with A or D.
- Press S to mark the start crossing.
- Step forward to the second ball crossing with D.
- Press E to mark the end crossing.
You'll see a positive time. The same line shows m/s plus km/h plus mph.
The estimate warning stays visible at the bottom of the window. This keeps the measurement framed as average video-time speed.
- Press R to clear the crossing marks.
The crossing frame labels disappear. The speed line disappears while both gate guides remain.
You have a repeatable 3 m speed estimate. Each reset gives you another chance to refine the two crossing frames.
Speed line missing?
Confirm that you pressed S before E. The end crossing must use a later frame than the start crossing.
Check that both gate coordinates remain visible. Help me debug a missing speed result.
Secret mission
Show the Measurement Uncertainty
A fast ball can cross each gate between recorded frames. Add a low-to-high km/h range that shows how one-frame timing uncertainty changes the estimate.
Clean Up Your Resources
Clean Up Your Resources
Your OpenCV app and project files stay on your Windows computer with no ongoing service costs. Decide whether to keep the app open, pause it for later, or delete the local workspace.
Resources you used:
- Local virtual environment in .venv.
- Dependency pin in requirements.txt.
- Analyzer source code in serve_speed_lab.py.
- Practice footage in serve.mp4.
Keep everything running
No action is needed. Choose this if you are still comparing crossing frames or testing another measurement.
- Keep the OpenCV window open while you compare crossing frames.
- Keep the volleyball-speed-lab folder on your computer for future clips.
Pause - I'll come back to this later
Shut down the running app to free its window. Your files remain ready for your next measurement session.
- Return to the OpenCV window from earlier.
- Press Q to close the app.
- Leave the volleyball-speed-lab folder on your computer.
The OpenCV window closes. Your script, virtual environment, dependency file, and practice footage remain available.
Delete - I don't want to use this again
Remove the local workspace when you no longer need the analyzer. This frees the disk space used by the virtual environment and video.
Deletion Is Permanent
Permanent deletion can feel drastic. The volleyball-speed-lab folder contains every local artifact created for this project.
- Copy any files you want to keep into another folder.
- Close the OpenCV window by pressing Q if the app is running.
- Close Visual Studio Code.
- Press the Windows key to open search.
- Type File Explorer into the search field.
- Press Enter.
- Locate the volleyball-speed-lab folder where you saved it.
- Select the volleyball-speed-lab folder.
- Press Shift+Delete to permanently delete it.
- Confirm the permanent deletion in the Windows dialog.
You should no longer see the volleyball-speed-lab folder in its original location. The .venv, requirements.txt, serve_speed_lab.py, and serve.mp4 resources are removed with it.
Nice Work!
Nice Work!
You did it! Your Volleyball Serve Speed Lab now turns manually verified frame crossings into an honest average-speed estimate.
You've learned how to:
- Built a Windows OpenCV video tool that opens local volleyball footage. The app displays its reported frame rate. It also lets you inspect the clip one frame at a time.
- Added an interactive two-gate workflow for a known court segment. Frame-by-frame controls let you mark both crossing moments.
- Converted a marked 3 m crossing into an average-speed estimate. The result appears in m/s, km/h, plus mph. A permanent footage-quality warning keeps the claim honest.
- Secret Mission: Added a one-frame timing uncertainty range that shows how early or late crossing marks can change the km/h estimate.
Ready to quiz yourself?