AI & Computer Vision, lesson 5 of 52

Video Basics: Frames, FPS, Reading & Saving Video

beginner20 min read

Introduction

Every match replay, every VAR clip and every highlight reel is the same thing underneath: a stack of still pictures. Today you'll open video files frame by frame, record your own webcam to a file with the time stamped on every frame, and make a slow-motion copy, and find out why it looks jerky.

By the end of this lesson you'll be able to:

  • Explain that video is a stack of still frames shown quickly, and say what FPS and resolution mean
  • Read a video file frame by frame with cv2.VideoCapture
  • Read a video's FPS, frame count and size from its properties
  • Save frames to a new file with cv2.VideoWriter, with the right codec and frame size
  • Put a timestamp on every frame
  • Explain how slowing a clip down differs from recording at a higher frame rate

Working principle

Video is frames

  • A frame is one still image, exactly the array you met in lesson 3.
  • FPS is how many frames are shown per second. Webcams usually give 15 to 30. Phones give 30 or 60.
  • Resolution is the frame width and height in pixels, for example 640 by 480 or 1920 by 1080.
  • A codec compresses the frames to save space (for example H.264). A container (.mp4, .avi) is the file that holds them. OpenCV picks the codec with a four-letter code called a fourcc. mp4v is the safe choice for .mp4 files.

Each page is a frame. This kick took 8 frames. Page 1 of 8.

At 30 FPS, a 10-second clip is 300 frames. At 1920 by 1080 in colour, uncompressed, that is almost 2 GB. Compression matters.

Reading a video file

import cv2
 
cap = cv2.VideoCapture("clip.mp4")          # a file name instead of 0 for the webcam
fps = cap.get(cv2.CAP_PROP_FPS)
frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
print(f"{w}x{h}, {fps:.1f} FPS, {frames} frames, {frames / fps:.1f} seconds")
 
count = 0
while True:
    ret, frame = cap.read()
    if not ret:             # no more frames: the file has ended
        break
    count += 1
 
cap.release()
print("Frames read:", count)

For a file, ret becoming False means the video ended. For a webcam it can also mean the camera was disconnected.

Writing a video file

fourcc = cv2.VideoWriter_fourcc(*"mp4v")
out = cv2.VideoWriter("copy.mp4", fourcc, fps, (w, h))   # size is (width, height)
...
out.write(frame)          # the frame MUST be exactly (w, h)
...
out.release()             # without this the file is broken or empty

The three classic mistakes: giving the size as (height, width), writing a frame that is a different size, and forgetting release(). All three give you a file that won't play.

Timestamps and slow motion

  • Wall-clock timestamp: datetime.now().strftime("%H:%M:%S.%f")[:-3]
  • Video timestamp: frame_index / fps seconds into the clip. Use this when reading a file, because the wall clock is when you ran the script, not when the video was filmed.
  • Slow motion (cheap): write the same frames with a lower FPS. 30 FPS written as 15 FPS plays at half speed. It will look choppy.
  • Time-lapse: the opposite. Keep every 10th frame and write at 30 FPS.

Many webcams report an FPS of 0. If so, measure it: count frames over a few seconds, and use 30 as a fallback.

What you'll need

  • Your cv_course environment from lesson 2
  • A webcam
  • A short video clip (match.mp4). No clip? demo_clip.py below makes a pretend one.

Build it

Helper: demo_clip.py (a fake match clip)

Save this once. Every later lab can call it, so nobody is stuck without footage. The orange ball and the blue and red "players" move across a green pitch.

# demo_clip.py: makes a fake match clip so every lab works without real footage
import cv2
import numpy as np
 
 
def make_demo_clip(path="demo_clip.mp4", seconds=6, fps=30, size=(640, 360)):
    w, h = size
    out = cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*"mp4v"), fps, size)
    total = seconds * fps
    for i in range(total):
        t = i / total
        f = np.zeros((h, w, 3), np.uint8)
        f[:] = (34, 142, 28)                                   # grass (B, G, R)
        cv2.rectangle(f, (20, 20), (w - 20, h - 20), (255, 255, 255), 2)
        cv2.line(f, (w // 2, 20), (w // 2, h - 20), (255, 255, 255), 2)
        cv2.circle(f, (w // 2, h // 2), 40, (255, 255, 255), 2)
 
        bx = int(40 + t * (w - 80))                            # the orange ball
        by = int(h / 2 + 60 * np.sin(t * 6.28 * 2))
        cv2.circle(f, (bx, by), 9, (0, 140, 255), -1)
 
        teams = [(160, (200, 60, 30)), (300, (200, 60, 30)),   # blue shirts
                 (420, (30, 30, 210)), (520, (30, 30, 210))]   # red shirts
        for k, (x0, colour) in enumerate(teams):
            px = int(x0 + 25 * np.sin(t * 6.28 + k))
            py = int(h / 2 + 80 * np.cos(t * 6.28 * 1.5 + k))
            cv2.circle(f, (px, py), 14, colour, -1)
        out.write(f)
    out.release()
    return path
 
 
if __name__ == "__main__":
    print("Made", make_demo_clip())

Step 1: inspect a clip

import os
import cv2
from demo_clip import make_demo_clip
 
path = "match_clip.mp4"
if not os.path.exists(path):          # no real clip? make the fake one
    path = make_demo_clip()
 
cap = cv2.VideoCapture(path)
fps = cap.get(cv2.CAP_PROP_FPS)
frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
print(f"{path}: {w}x{h}, {fps:.1f} FPS, {frames} frames, {frames / fps:.1f} s")
 
count = 0
while True:
    ret, frame = cap.read()
    if not ret:
        break
    count += 1
cap.release()
print("Counted frames:", count)

Question: is the counted number the same as CAP_PROP_FRAME_COUNT? Why might they differ a little?

Step 2: record 10 seconds from the webcam, with a timestamp

import time
from datetime import datetime
import cv2
 
cap = cv2.VideoCapture(0)
if not cap.isOpened():
    raise SystemExit("Cannot open webcam. Try index 1 or 2.")
 
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
cam_fps = cap.get(cv2.CAP_PROP_FPS)
fps = cam_fps if cam_fps and cam_fps > 1 else 30.0    # many webcams report 0
 
out = cv2.VideoWriter("my_clip.mp4", cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h))
 
start = time.time()
frames_written = 0
 
while time.time() - start < 10:                        # record for 10 seconds
    ret, frame = cap.read()
    if not ret:
        break
 
    stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
    cv2.putText(frame, stamp, (10, h - 12), cv2.FONT_HERSHEY_SIMPLEX,
                0.6, (0, 255, 255), 2, cv2.LINE_AA)
 
    out.write(frame)
    frames_written += 1
    cv2.imshow("Recording", frame)
    if cv2.waitKey(1) & 0xFF == ord("q"):
        break
 
elapsed = time.time() - start
cap.release()
out.release()
cv2.destroyAllWindows()
 
real_fps = frames_written / elapsed
print(f"{frames_written} frames in {elapsed:.1f} s = {real_fps:.1f} real FPS")

Question: the real FPS may be lower than the camera's claimed FPS. Why does that matter when the clip is played back at the claimed FPS?

Step 3: slow-motion copy, and one frame as a picture

import cv2
 
src = "my_clip.mp4"
cap = cv2.VideoCapture(src)
fps = cap.get(cv2.CAP_PROP_FPS)
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
 
# Same frames, written at half the FPS: plays at half speed
slow = cv2.VideoWriter("my_clip_slow.mp4", cv2.VideoWriter_fourcc(*"mp4v"),
                       fps / 2, (w, h))
 
index = 0
while True:
    ret, frame = cap.read()
    if not ret:
        break
    seconds = index / fps                                     # video time, not wall-clock time
    cv2.putText(frame, f"t = {seconds:5.2f} s", (10, 30),
                cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2, cv2.LINE_AA)
    slow.write(frame)
    index += 1
 
slow.release()
 
# Jump to frame 30 and save it as a picture
cap.set(cv2.CAP_PROP_POS_FRAMES, 30)
ret, frame = cap.read()
if ret:
    cv2.imwrite("frame_30.jpg", frame)
cap.release()
print("Done:", index, "frames")

Question: how long is the slow-motion clip compared with the original? What would you change to make it 4 times slower?

If it goes wrong

ErrorCauseFix
Output file is 0 KB or won't playout.release() missing, or frame size doesn't matchCall release(). Make sure each frame is exactly (w, h).
cv2.VideoWriter seems to work but writes nothingSize given as (height, width)Use (width, height)
CAP_PROP_FPS returns 0The webcam doesn't report its FPSUse 30 as a fallback, or measure it
Clip plays too fast or too slowWritten FPS doesn't match the real capture rateMeasure real FPS, and write with that
cap.read() returns False immediatelyWrong file path, so the file didn't openCheck cap.isOpened() and the path
Slow-motion looks jerkyYou slowed the playback, not the captureExpected. Explain it.
Resizing a frame breaks the writerThe size changed after the writer was createdResize before writing and create the writer with the new size

Try it

  • Make a time-lapse: keep every 5th frame and write at the original FPS
  • Add a frame counter and a red recording dot to the overlay
  • Resize frames to half size before writing and compare file sizes

Check what you learnt

  1. How many frames are in a 10-second clip at 30 FPS?

  2. While reading a file, ret comes back False. What does that mean?

  3. Why must VideoWriter's frame size match the frames you write?

  4. What's the difference between a codec and a container?

  5. Which gives smooth slow motion?

Conclusion

Video is frames shown quickly. cv2.VideoCapture reads them one at a time from a camera or a file, its properties tell you the FPS and size, and cv2.VideoWriter saves frames back out (same size, a codec and a container). Smooth slow motion needs more frames recorded, not slower playback.

What next?

Next: JS for CV: browser vision basics. You'll do the same in a web browser, with JavaScript.