Sitemap
Rectlabs Inc

Enterprise-level AI for businesses

Video Classification Using SpatioTemporal Transformers

6 min readApr 4, 2025

--

Background

In a previous series on transformers, we highlighted their advantages over RNNs and LSTMs for sequential data processing. Unlike sequential modelling in RNNs, transformers use attention mechanisms to capture long-range dependencies in a single pass, improving context retention and gradient flow via residual connections. Parallel processing also enables it in learning multiple attention contexts simultaneously, enhancing efficiency. This has led to architectures like BERT, enabling high-performance transfer learning across NLP, time-series forecasting, and vision tasks.

Speaking of vision tasks; the vision transformer architecture (ViT) improved image recognition performance with less computational complexity that state-of-the-art CNNs, becoming foundational for tasks like segmentation and detection. While BERTs and ViTs handle 1D sequences, video tasks require 2D spatiotemporal modelling. Building on the ViT architecture, a spatiotemporal transformer with sequential spatial and temporal attention outperforms state-of-the-art models for video classification like I3D and SlowFast while reducing training time.

ViT & Spatiotemporal Transformer

The ViT processes images by dividing, say a 224x224 image into 256 patches (16x 16) 0f 196 pixels, the embedding each 196-pixel patch. The resultant embeddings are combined with positional encodings to retain spatial information. A CLS token is appended to aggregate information for prediction before feeding the sequence into a transformer encoder for final prediction.

The spatiotemporal transformer classifies image sequences by processing, say a Tx224x224 input, dividing each frame into 256 patches (16x16) of 196 pixels and embedding them. This forms a Tx16x16xE representation, combined with positional encoding to retain spatial and temporal positions. It applies temporal attention to model patch evolution over time for each patch using a time encoder, then spatial attention between patches within each frame using a spatial encoder. The result is flattened to Tx256xE, with a CLS token added to aggregate learned structures for final prediction.

Get Emmanuel Olateju’s stories in your inbox

Join Medium for free to get updates from this writer.

Now that we understand how a spatiotemporal transformer works, let us implement one for the open source UCF50-Action-Recognition dataset.

Implementation Details

Rather than develop or spatiotemporal transformer from scratch, we make use of the huggingface TimesFormer package which implements the facebook spatiotemporal transformer described above and referred to as the TimesFormer.

Implementation

We begin by defining or clip parameters: the number of frames for classification and the height and width of frames, then we prepare our dataset to match this.

import os
import sys
import random
import math

import cv2

from sklearn.model_selection import StratifiedShuffleSplit

import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, Subset, DataLoader
import torch.nn.functional as F
from einops import rearrange

DEVICE = "cuda" if torch.cuda.is_available() else "cpu"



T = SEQUENCE_LENGTH = 20
H, W = HEIGHT, WIDTH = 224, 224

ucf_path = "/kaggle/input/ucf50-action-recognition-dataset/UCF50"
actions = os.listdir(ucf_path)
N_CLASSES = 5
actions = ['PlayingTabla', 'HulaHoop', 'Lunges', 'Swing', 'Skijet']
# actions = random.sample(actions, N_CLASSES)
print(actions)

NC = 50


X = np.empty((0, T, H, W, 3))
y = []

for a, action in enumerate(actions):
N = X.shape[0]
clips = os.listdir(os.path.join(ucf_path, action))
for clip in tqdm(clips, desc=f"Extracting {action} Clip {a+1}/{len(actions)}", total=len(clips)):
path = os.path.join(ucf_path, action, clip)
cap = cv2.VideoCapture(path)

if not cap.isOpened():
continue

frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) # Total frames
frames = []
for f in range(frame_count):
ret, frame = cap.read()
if not ret:
break

frame = cv2.resize(frame, (HEIGHT, WIDTH))
frame = frame/255
frames.append(frame)

if len(frames)==T:
X = np.vstack((X, np.expand_dims(np.array(frames), axis=0)))
y.append(a)
frames = []

if X.shape[0] >= N + NC:
break

cap.release()

if X.shape[0] >= N + NC:
break

X = torch.tensor(X).to(torch.float32)
Y = torch.tensor(y)

X = rearrange(X, "b t h w c -> b t c h w")

time.sleep(0.25)
clear_output()

print(X.size(), Y.size())

Now let us make a PyTorch dataset and dataloader

class UCF50Dataset(Dataset):

def __init__(self, X, y):
assert X.size(0) == len(y)

self.X = X
self.y = y

def __len__(self):
return self.X.size(0)

def __getitem__(self, idx):
if self.y != None:
return self.X[idx], self.y[idx]
return self.X[idx]

dataset = UCF50Dataset(X, Y)

train_batch_size = 5
val_batch_size = 3
print(train_batch_size, val_batch_size)

N = len(dataset)
split_ratio = 0.8
split_size = int(0.8*N)

split = StratifiedShuffleSplit(n_splits=1, test_size=1-split_ratio, random_state=42)
train_idx, val_idx = next(split.split(range(N), dataset.y))

train_dataset = Subset(dataset, train_idx)
val_dataset = Subset(dataset, val_idx)

train_loader = DataLoader(train_dataset, batch_size=train_batch_size, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=val_batch_size, shuffle=True)

print(f"Train samples: {len(train_dataset)}, Test samples: {len(val_dataset)}")

Now let us define our TimesFormer hyperparameters

from transformers import TimesformerConfig, TimesformerModel, TimesformerForVideoClassification
from transformers import VivitConfig, VivitModel

N_PATCHES = 64
N_EMBD = 768
N_ATTN_HEADS = 6
N_LAYERS = 2
FF_INTERMEDIATE = 3072
INIT_STD = 0.2

timesformer_config_dict = {
"image_size": HEIGHT,
"patch_size": N_PATCHES,
"num_channels": 3,
"num_frames": T,
"hidden_size": N_EMBD,
"num_hidden_layers":N_LAYERS,
"num_attention_heads": N_ATTN_HEADS,
"intermediate_size": FF_INTERMEDIATE,
"hidden_act": "leaky_relu",
"hidden_dropout_prob": 0.05,
"attention_probs_dropout_prob": 0.1,
"initializer_range": INIT_STD,
"layer_norm_eps": 1E-6,
"qkv_bias": False,
"attention_type": "divided_space_time",
"drop_path_rate": 0.2,
}

timesformer_config = TimesformerConfig(**timesformer_config_dict)

Now we can define our model which creates a TimesFormer instance, and then adds an extra layer computes the logits for softmax classification using the learned CLS token.

class ActionClassifier(nn.Module):

def __init__(self, n_embd, n_classes, TimesFormer):
super(ActionClassifier, self).__init__()
self.timesformer = TimesFormer
self.cls_layer = nn.Linear(N_EMBD, N_CLASSES)

def forward(self, x, y=None, device=DEVICE):
out = self.timesformer(x)
embeddings = out.last_hidden_state
cls_token = embeddings[:, 0, :]

logits = F.leaky_relu(self.cls_layer(cls_token))
if y==None:
return logits
elif isinstance(y, torch.Tensor):
loss = F.cross_entropy(logits, y, label_smoothing=0.1)
acc = accuracy_score(logits, y)
return logits, loss, acc



model = ActionClassifier(N_EMBD, N_CLASSES, TimesformerModel(timesformer_config))
model =model.to(DEVICE)

def init_weights(m):
if isinstance(m, nn.Linear): # Apply only to Linear layers
torch.nn.init.normal_(m.weight, mean=0.0, std=INIT_STD)
if m.bias is not None:
torch.nn.init.zeros_(m.bias) # Set bias to 0

model.apply(init_weights)

Then we can train our model

torch.manual_seed(42)
np.random.seed(42)

n_iter = 3000
eval_inter = 5
n_evals = 3

lr_ = 3E-4
betas_ = (0.9, 0.999)
optimizer = optim.Adam(model.parameters(), lr=lr_, betas=betas_)
lr_scheduler = LRScheduler(lr_, patience=5, factor=1, margin=0.05, n_evals=n_iter//n_evals)

early_stop = EarlyStopping(patience=10, margin=0.1)

chk_pt = CheckpointModel(margin=0.05)

history = {
"train_loss": [],
"val_loss": [],
"train_acc": [],
"val_acc": [],
"iter": [],
"lr": [],
}

for i in tqdm(range(n_iter), total=n_iter):

if i==0 or i%eval_inter==0 or i>=n_iter-1:
model = model.eval()
with torch.no_grad():

train_loss=torch.zeros((n_evals,))
train_acc=torch.zeros((n_evals,))
for j in range(n_evals):
with torch.autocast(device_type=DEVICE):
x, y = next(iter(train_loader))
x = x.to(DEVICE)
y = y.to(DEVICE)
logits, loss, acc = model(x, y, device=DEVICE)
train_loss[j] = loss
train_acc[j] = acc

val_loss = torch.zeros((n_evals,))
val_acc = torch.zeros((n_evals,))
for j in range(n_evals):
with torch.autocast(device_type=DEVICE):
x, y = next(iter(val_loader))
x = x.to(DEVICE)
y = y.to(DEVICE)
logits, loss, acc = model(x, y, device=DEVICE)
val_loss[j] = loss
val_acc[j] = acc

train_loss = train_loss.mean().item()
train_acc = train_acc.mean()
val_loss = val_loss.mean().item()
val_acc = val_acc.mean()

# lr_ = lr_scheduler.exponential_lr(val_loss)
# for param_group in optimizer.param_groups:
# param_group["lr"] = lr_

history["train_loss"].append(train_loss)
history["val_loss"].append(val_loss)
history["train_acc"].append(train_acc)
history["val_acc"].append(val_acc)
history["iter"].append(i)
history["lr"].append(lr_)

early_stop_signal = early_stop.check(train_loss, val_loss)
if early_stop_signal == True:
break

chk_pt.checkpoint(val_loss, model)

model = model.train()
optimizer.zero_grad()
with torch.autocast(device_type=DEVICE):
x, y = next(iter(train_loader))
x, y = x.to(DEVICE), y.to(DEVICE)
logits, loss, acc = model(x, y, device=DEVICE)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()

After training, let us see our training loss, accuracy, and confusion matrix, which tells us how well our model performs on the validation set.

Press enter or click to view image in full size
Loss & Accuracy
Validation Set Confusion Matrix

Real Time Inference

Let us run inference on some clips with the resulting video shown

model.load_state_dict(chk_pt.best_state)
model.eval()

fourcc = cv2.VideoWriter_fourcc(*'mp4v')
output_video_path = "continuous_inference.mp4"
out = None

for a, action in enumerate(actions):
clips = os.listdir(os.path.join(ucf_path, action))[:5]
for clip in tqdm(clips, desc=action):
path = os.path.join(ucf_path, action, clip)
cap = cv2.VideoCapture(path)

if not cap.isOpened():
print(f"Skipping file: {path}")
continue

frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
fps = int(cap.get(cv2.CAP_PROP_FPS))
if out is None:
out = cv2.VideoWriter(output_video_path, fourcc, fps, (width, height))
frames = []

for f in range(frame_count):
ret, frame = cap.read()
if not ret:
break

frame_resized = cv2.resize(frame, (HEIGHT, WIDTH))
frame_resized = frame_resized/255
if len(frames) == T:
frames = frames[1:]
frames.append(frame_resized)

if len(frames)==T:
X = torch.tensor(np.array(frames), dtype=torch.float32).to(DEVICE)
X = X.unsqueeze(dim=0)
X = rearrange(X, "b t h w c -> b t c h w")

logits = model(X)
probs = F.softmax(logits, dim=-1)
pred = torch.argmax(probs, dim=-1)[0].item()
pred = actions[pred]

font = cv2.FONT_HERSHEY_SIMPLEX
cv2.putText(frame, f"Prediction: {pred}", (20,50), font, 0.5, (0, 255, 0), 2, cv2.LINE_AA)

out.write(frame)

cap.release()

if out is not None:
out.release()

print(f"Saved classified video: {output_video_path}")
Real Time Video Inference

Conclusion

In this write-up, we explored spatiotemporal transformers for video classification, building on the foundational ViT architecture. We implemented a TimesFormer model using Hugging Face’s package and applied it to the UCF50-Action-Recognition dataset. Our approach involved preprocessing video clips, defining a custom dataset, and training a transformer-based model capable of capturing both spatial and temporal dependencies.

The results highlight the potential of spatiotemporal transformers in video classification, potentially outperforming traditional CNN-based models while maintaining computational efficiency. Our validation metrics and real-time inference demonstrate the model’s ability to generalize well across diverse action categories.

--