Dua chapter sebelumnya menyatukan teks dengan suara; kini giliran modalitas yang paling rakus memori: video. Sebuah video pada dasarnya adalah deretan gambar — tetapi justru deretan itulah masalahnya, karena makna sebuah aksi (“melempar”, “berjalan menuju pintu”) tidak ada pada satu bingkai mana pun, melainkan pada perubahan antar bingkai. Video understanding karenanya selalu berhadapan dengan dua keputusan desain: bagaimana memadatkan puluhan bingkai menjadi representasi tunggal, dan bingkai mana yang perlu diambil dari ratusan cuplikan tersedia. Chapter ini memetakan tugas dan arsitekturnya, lalu mengujinya pada demo yang jujur: dua model kecil mendeteksi arah gerak, dan salah satunya gagal dengan cara yang mendidik.

Video sebagai Tensor (T, C, H, W) #

Satu klip video disimpan sebagai tensor berdimensi empat: (T, C, H, W) — T bingkai, C kanal warna, H baris, W kolom. Dimensi waktu sengaja ditulis di depan: bingkai adalah elemen urutan, seperti token pada teks. Konsekuensi memorinya cepat membengkak: 8 bingkai RGB 224x224 dalam float32 sudah memakan 8 × 3 × 224 × 224 × 4 ≈ 4,8 MB, sedangkan satu menit video 30 fps berisi 1.800 bingkai — lebih dari 1 GB untuk satu sampel mentah. Efisiensi karena itu bukan pelengkap, melainkan bagian dari desain: video dipangkas, diubah resolusinya, dan terutama disampel.

Diagram di atas adalah kerangka yang dipakai hampir semua sistem, dan dua kotak tengahnya adalah tempat keluarga arsitektur dibedakan satu sama lain.

Peta Tugas Video #

TugasKeluaranContoh pertanyaan
Klasifikasi aksisatu label per klip“aksi apa yang sedang dilakukan?”
Deteksi temporalbatas awal-akhir kejadian“kapan lemparan terjadi di Highlight ini?”
Retrieval momenklip yang cocok dengan kueri teks“temukan bagian saat bola masuk gawang”
Peringkasan/captioning videokalimat deskriptif per klip“apa yang terjadi dalam klip ini?”

Klasifikasi aksi adalah tugas paling dasar dan menjadi tolok ukur utama dataset besar seperti Kinetics. Tiga tugas lainnya menuntut model yang tidak hanya mengenali isi tetapi juga menjaga informasi kapan — dan di sinilah arsitektur yang merata-ratakan waktu mulai kehilangan daya.

Tiga Keluarga Arsitektur #

KeluargaCara mengolah waktuKekuatan khas
CNN per bingkai + temporal poolingfitur tiap bingkai dirata-rata/di-maxmurah, encoder gambar bisa dipakai ulang
CNN 3Dconvolution lintas waktu sejak lapisan awalmenangkap pola gerak lokal
Video transformertoken patch per bingkai + attention temporalrelasi antar bingkai jauh

Keluarga pertama adalah yang paling murah dan paling mudah disalahpahami — rata-rata temporal membuang informasi urutan, sehingga “orang bergerak ke kanan” dan “orang bergerak ke kiri” menghasilkan fitur yang sama persis; demo kita akan membuktikan gejala ini secara terukur. Keluarga kedua memperluas convolution ke sumbu waktu (kernel 3x3x3) sehingga pola gerak terpelajari sejak lapisan awal — jalur yang dipopulerkan C3D dan I3D (arxiv.org/abs/1711.07971 ). Keluarga ketiga memperlakukan patch tiap bingkai sebagai token lalu memakai attention lintas waktu: TimeSformer (arxiv.org/abs/2102.05095 ) membagi attention spasial dan temporal bergantian, ViViT (arxiv.org/abs/2103.15691 ) mengusulkan beberapa pemenggalan token — keduanya perluasan langsung dari vision transformer di chapter Vision Transformer .

Sebelum era transformer, jurus klasik untuk menyelamatkan informasi arah adalah arsitektur two-stream: satu jalur membaca bingkai (spatial stream), satu jalur membaca optical flow — peta perpindahan piksel antar bingkai (temporal stream). Demo kita memakai versi paling primitif dari ide itu: selisih dua bingkai berurutan.

Demo: Dua Model Mendeteksi Arah Gerak #

Data kita adalah video sintetis 8 bingkai berukuran 4x4 dengan satu titik yang bergerak lurus satu langkah setiap dua bingkai. Model A membaca rata-rata seluruh bingkai (temporal pooling); model B membaca rata-rata bingkai selisih frame[t+1] - frame[t]. Keduanya memakai MLP kecil yang sama persis, sehingga satu-satunya perbedaan adalah fitur:

python
"""Video understanding mini: video sintetis (T, C, H, W) berisi satu
titik yang bergerak lurus; dua model kecil bersaing mendeteksi arah.

- Data : 8 bingkai 4x4 (T=8, C=1, H=W=4); titik berpindah satu langkah
  setiap dua bingkai -> 4 posisi, masing-masing terlihat dua kali.
  4 kelas arah: kanan, kiri, atas, bawah; koordinat sisi diam diacak
  agar tiap sampel berbeda.
- Model A (temporal pooling rata-rata): fitur = rata-rata semua
  bingkai. Titik yang bergerak kanan dan kiri melewati kumpulan
  posisi yang sama, sehingga fiturnya identik -> arah tak terbedakan.
- Model B (selisih temporal): fitur = rata-rata bingkai selisih
  frame[t+1]-frame[t] (padanan primitif optical flow) -> tanda +/-
  mempertahankan arah gerak.

Uji nyata: akurasi uji per kelas kedua model pada 80 video uji.
CPU, PyTorch 2.14, deterministik penuh.
"""

import torch
import torch.nn as nn

torch.manual_seed(7)

T, C, H, W = 8, 1, 4, 4
ARAH = ["kanan", "kiri", "atas", "bawah"]
VEKTOR = {"kanan": (0, 1), "kiri": (0, -1),
          "atas": (-1, 0), "bawah": (1, 0)}


def buat_video(kelas, generator):
    """Video tensor (T, C, H, W): titik bergerak 1 langkah / 2 bingkai."""
    video = torch.zeros(T, C, H, W)
    dr, dc = VEKTOR[ARAH[kelas]]
    if dr != 0:  # gerak tegak: kolom tetap diacak
        c_tetap = int(torch.randint(0, W, (1,), generator=generator))
        mulai = 0 if dr > 0 else H - 1
        posisi = [(mulai + dr * (t // 2), c_tetap) for t in range(T)]
    else:        # gerak mendatar: baris tetap diacak
        r_tetap = int(torch.randint(0, H, (1,), generator=generator))
        mulai = 0 if dc > 0 else W - 1
        posisi = [(r_tetap, mulai + dc * (t // 2)) for t in range(T)]
    for t, (r, c) in enumerate(posisi):
        video[t, 0, r, c] = 1.0
    return video


def buat_dataset(n_per_kelas, seed):
    generator = torch.Generator().manual_seed(seed)
    X, y = [], []
    for kelas in range(len(ARAH)):
        for _ in range(n_per_kelas):
            X.append(buat_video(kelas, generator))
            y.append(kelas)
    return torch.stack(X), torch.tensor(y)


def tampil_grid(video, judul):
    """Cetak delapan bingkai video sebagai grid karakter 4x4."""
    print(judul)
    for mulai in range(0, T, 4):
        print(" ".join(f"{'bingkai ' + str(t + 1):^11}"
                       for t in range(mulai, mulai + 4)))
        for r in range(H):
            print(" ".join(
                f"{' '.join('#' if video[t, 0, r, c] > 0 else '.'
                            for c in range(W)):^11}"
                for t in range(mulai, mulai + 4)))
        print()


def tampil_selisih(video):
    """Cetak bingkai selisih tempat perpindahan titik terjadi."""
    diff = video[1:] - video[:-1]          # (T-1, C, H, W)
    print("bingkai selisih frame[t+1]-frame[t] ( + muncul, - hilang ):")
    for t in (1, 3, 5):
        if float(diff[t].abs().sum()) == 0:
            continue
        print(f"  t={t}: " + " | ".join(
            "".join("+" if diff[t, 0, r, c] > 0 else
                    "-" if diff[t, 0, r, c] < 0 else "."
                    for c in range(W)) for r in range(H)))


print("=== 1. Satu video contoh: arah 'kanan' ===")
video0 = buat_video(0, torch.Generator().manual_seed(11))
tampil_grid(video0, f"video (T, C, H, W) = {tuple(video0.shape)}, "
                   f"arah sejati '{ARAH[0]}':")
tampil_selisih(video0)

X_latih, y_latih = buat_dataset(60, 1)
X_uji, y_uji = buat_dataset(20, 2)
print(f"latih: {tuple(X_latih.shape)}  (n, T, C, H, W)")
print(f"uji  : {tuple(X_uji.shape)}")
print("fitur model A : rata-rata bingkai         -> "
      f"{tuple(X_latih.mean(dim=1).flatten(1).shape)}")
print("fitur model B : rata-rata bingkai selisih -> "
      f"{tuple((X_latih[:, 1:] - X_latih[:, :-1]).mean(dim=1).flatten(1).shape)}")


def latih_model(fitur, label, epoch=300, lr=0.03):
    """MLP kecil dua lapis pada fitur statis; kembalikan model."""
    model = nn.Sequential(nn.Linear(fitur.shape[1], 32), nn.Tanh(),
                          nn.Linear(32, len(ARAH)))
    opt = torch.optim.Adam(model.parameters(), lr=lr)
    loss_fn = nn.CrossEntropyLoss()
    for e in range(epoch):
        opt.zero_grad()
        loss = loss_fn(model(fitur), label)
        loss.backward()
        opt.step()
    return model, loss.item()


def lapor(model, fitur, label, nama):
    with torch.no_grad():
        pred = model(fitur).argmax(1)
    ak = (pred == label).float().mean().item()
    print(f"{nama}: akurasi uji {ak:.3f}")
    for k, nama_kelas in enumerate(ARAH):
        mask = label == k
        ak_k = (pred[mask] == k).float().mean().item()
        print(f"  kelas {nama_kelas:6s}: {ak_k:.3f}")
    return pred


print()
print("=== 2. Model A: temporal pooling rata-rata ===")
fA_latih = X_latih.mean(dim=1).flatten(1)
fA_uji = X_uji.mean(dim=1).flatten(1)
modelA, lossA = latih_model(fA_latih, y_latih)
print(f"loss akhir latih {lossA:.4f}")
predA = lapor(modelA, fA_uji, y_uji, "model A")

print()
print("=== 3. Model B: fitur selisih temporal ===")
fB_latih = (X_latih[:, 1:] - X_latih[:, :-1]).mean(dim=1).flatten(1)
fB_uji = (X_uji[:, 1:] - X_uji[:, :-1]).mean(dim=1).flatten(1)
modelB, lossB = latih_model(fB_latih, y_latih)
print(f"loss akhir latih {lossB:.4f}")
predB = lapor(modelB, fB_uji, y_uji, "model B")

print()
print("=== 4. Bukti fitur identik pada arah berlawanan ===")
g1 = torch.Generator().manual_seed(21)
g2 = torch.Generator().manual_seed(21)
v_kanan, v_kiri = buat_video(0, g1), buat_video(1, g2)
fA_kanan = v_kanan.mean(dim=0).flatten()
fA_kiri = v_kiri.mean(dim=0).flatten()
fB_kanan = (v_kanan[1:] - v_kanan[:-1]).mean(dim=0).flatten()
fB_kiri = (v_kiri[1:] - v_kiri[:-1]).mean(dim=0).flatten()
sama = bool(torch.equal(fA_kanan, fA_kiri))
jarakB = float((fB_kanan - fB_kiri).abs().sum())
print(f"video 'kanan' vs 'kiri' pada baris sama: fitur model A "
      f"identik = {sama}, jarak L1 fitur model B = {jarakB:.3f}")

Output yang Dihasilkan:

text
=== 1. Satu video contoh: arah 'kanan' ===
video (T, C, H, W) = (8, 1, 4, 4), arah sejati 'kanan':
 bingkai 1   bingkai 2   bingkai 3   bingkai 4 
  . . . .     . . . .     . . . .     . . . .  
  # . . .     # . . .     . # . .     . # . .  
  . . . .     . . . .     . . . .     . . . .  
  . . . .     . . . .     . . . .     . . . .  

 bingkai 5   bingkai 6   bingkai 7   bingkai 8 
  . . . .     . . . .     . . . .     . . . .  
  . . # .     . . # .     . . . #     . . . #  
  . . . .     . . . .     . . . .     . . . .  
  . . . .     . . . .     . . . .     . . . .  

bingkai selisih frame[t+1]-frame[t] ( + muncul, - hilang ):
  t=1: .... | -+.. | .... | ....
  t=3: .... | .-+. | .... | ....
  t=5: .... | ..-+ | .... | ....
latih: (240, 8, 1, 4, 4)  (n, T, C, H, W)
uji  : (80, 8, 1, 4, 4)
fitur model A : rata-rata bingkai         -> (240, 16)
fitur model B : rata-rata bingkai selisih -> (240, 16)

=== 2. Model A: temporal pooling rata-rata ===
loss akhir latih 0.6798
model A: akurasi uji 0.562
  kelas kanan : 0.350
  kelas kiri  : 0.600
  kelas atas  : 0.600
  kelas bawah : 0.700

=== 3. Model B: fitur selisih temporal ===
loss akhir latih 0.0004
model B: akurasi uji 1.000
  kelas kanan : 1.000
  kelas kiri  : 1.000
  kelas atas  : 1.000
  kelas bawah : 1.000

=== 4. Bukti fitur identik pada arah berlawanan ===
video 'kanan' vs 'kiri' pada baris sama: fitur model A identik = True, jarak L1 fitur model B = 0.571

Mari kita telaah kode di atas baris per baris:

  1. Baris 30-44 (buat_video): generator video mengambil vektor arah lalu membangun daftar posisi mulai + arah * (t // 2) — titik berpindah setiap dua bingkai agar 8 bingkai memuat tepat 4 posisi berbeda yang masing-masing tampil dua kali. Koordinat sisi diam diacak supaya sampel-sampel tidak seragam.
  2. Baris 57-81 (tampil_grid, tampil_selisih): kedua fungsi pencetak ini membuat isi tensor terbaca mata — delapan bingkai grid #/. serta bingkai selisih bertanda + (titik muncul) dan - (titik hilang). Pada keluaran, pola -+.. bergeser ke kanan di t=1, 3, 5: itulah arah gerak yang harus ditangkap model.
  3. Baris 90-97: dataset berbentuk (n, T, C, H, W) — perhatikan sumbu waktu menjadi dimensi pertama batch, dan dua jenis fitur keduanya berukuran (240, 16); model yang sama berlaku untuk keduanya.
  4. Baris 100-111 (latih_model): MLP dua lapis dengan 32 unit tersembunyi dilatih 300 epoch — arsitektur dan pelatihan identik untuk kedua model, sehingga perbedaan hasil murni berasal dari fitur.
  5. Baris 128-132 vs 136-140: satu-satunya perbedaan antara model A dan B ada di dua baris fitur ini — X.mean(dim=1) merata-ratakan bingkai sehingga informasi urutan terbuang, sedangkan X[:, 1:] - X[:, :-1] menghitung selisih berurutan sebelum dirata-ratakan.
  6. Baris 143-154: dua generator berseed sama membangkitkan pasangan video kanan/kiri pada baris yang identik, lalu torch.equal membuktikan fitur model A kedua video itu persis sama, sementara jarak L1 fitur model B sebesar 0,571 menandakan keduanya terpisah.

Hasilnya tegas dan dapat dijelaskan sampai ke akarnya. Model A berhenti di 0,562 dengan akurasi per kelas yang timpang — arah kanan hanya 0,350 karena fiturnya kembar identik dengan kiri; model hanya bisa menebak sumbu gerak, bukan arahnya. Model B mencapai 1,000 pada semua kelas karena tanda +/- pada bingkai selisih mempertahankan urutan waktu. Pelajarannya setara dengan sejarah bidang ini: informasi gerak hidup di antara bingkai, sehingga jalur temporal (optical flow pada two-stream, convolution 3D, atau attention temporal pada video transformer) wajib ada di suatu tempat dalam arsitektur.

Kita kini telah menyusuri seluruh ragam modalitas: gambar dan teks disatukan lewat ruang bersama kontrastif, VLM membaca gambar sambil menalar bahasa, audio mengalir dua arah antara ucapan dan teks, dan video menambahkan sumbu waktu. Satu pertanyaan terakhir menutup section ini: bagaimana cara menilai semuanya secara adil? Chapter Evaluasi Model Multimodal memetakan metrik per tugas — recall@K untuk retrieval, akurasi VQA, hingga WER — dan menghitung masing-masing secara nyata pada contoh mini.