3. Compare 2 videos with SSIM and PSNR metrics

The classic metrics PSNR (Peak Signal to Noise Ratio) and SSIM (Structural SIMilarity) are implemented in C for improved performance.

[1]:
import math
import pathlib
import timeit

import cv2
import numpy as np
import tqdm

from cutcutcodec.core.io import read
from cutcutcodec.core.opti.parallel import starmap
from cutcutcodec.core.signal.metric import psnr as compute_psnr, ssim as compute_ssim  # the main functions

3.1. Preparing videos to be compared

Here we’ll compare the original video with a noisy version.

[2]:
from cutcutcodec.core.analysis.stream.rate_video import optimal_rate_video
from cutcutcodec.core.analysis.stream.shape import optimal_shape_video
from cutcutcodec.utils import get_project_root

file = get_project_root().parent / "media" / "video" / "intro.webm"
stream_ref = read(file).out_select("video")[0]
rate = optimal_rate_video(stream_ref)
shape = optimal_shape_video(stream_ref)
print(f"{file} at {rate} fps, of size {shape[1]}x{shape[0]}")
/data/Documents/cutcutcodec_git/media/video/intro.webm at 30000/1001 fps, of size 1280x720
[3]:
from cutcutcodec.core.filter.video.equation import FilterVideoEquation
from cutcutcodec.core.generation.video.noise import GeneratorVideoNoise

noise = GeneratorVideoNoise(0).out_streams[0]
stream_comp = FilterVideoEquation([stream_ref, noise], "(9*b0+b1)/10", "(9*g0+g1)/10", "(9*r0+r1)/10").out_streams[0]

3.2. Compare each frame

3.2.1. Convert all streams into YUV in the same colorspace

Image comparisons are always made in yuv, not rgb.

[4]:
from cutcutcodec.core.filter.video.colorspace import FilterVideoColorspace
from cutcutcodec.config import Config  # to get the default used colorspace

dst_color = f"y'pbpr_{Config().target_trc}_{Config().target_prim}"
ref_yuv = FilterVideoColorspace([stream_ref], dst=dst_color).out_streams[0]
comp_yuv = FilterVideoColorspace([stream_comp], dst=dst_color).out_streams[0]
[5]:
def compare(stream1, stream2, time, shape):
    """Compare 2 images."""
    frame1, frame2 = stream1.snapshot(time, shape), stream2.snapshot(time, shape)
    frame1, frame2 = frame1.numpy(force=True), frame2.numpy(force=True)
    psnr = compute_psnr(frame1, frame2, weights=(6, 1, 1))  # factor 6 on y
    ssim = compute_ssim(frame1, frame2, weights=(6, 1, 1), data_range=1.0)
    return psnr, ssim
[6]:
times = np.arange(0, ref_yuv.duration, 1/rate).tolist()
all_psnr, all_ssim = zip(*starmap(compare, ((ref_yuv, comp_yuv, t, shape) for t in tqdm.tqdm(times))))
100%|█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 294/294 [00:46<00:00,  6.31it/s]
[7]:
import matplotlib.pyplot as plt

plt.plot(times, all_ssim)
plt.xlabel("time (s)")
plt.ylabel("ssim")
plt.show()

plt.plot(times, all_psnr)
plt.xlabel("time (s)")
plt.ylabel("psnr (db)")
plt.show()
../../../_images/build_examples_basic_psnr_ssim_10_0.png
../../../_images/build_examples_basic_psnr_ssim_10_1.png

3.3. Compare performances on syntetic data

[8]:
# Functions as implemented in classical libraries.

def naive_psnr(im1: np.ndarray, im2: np.ndarray, weights = None) -> float:
    """Compute the psnr with numpy."""
    if weights is None:
      weights = [1.0 for _ in range(im1.shape[2])]
    layers_mse = ((im1 - im2)**2).mean(axis=(0, 1)).tolist()
    tot = sum(weights)
    mse = sum(l*w/tot for w, l in zip(weights, layers_mse))
    return -10.0*math.log10(mse) if mse > 1e-10 else 100.0

def naive_ssim(
  im1: np.ndarray, im2: np.ndarray, data_range : float = 1.0, weights = None, sigma: float = 1.5
) -> float:
    """Compute the ssim with numpy and cv2."""
    # get gaussian window
    r = int(3.5 * sigma + 0.5)  # same as skimage.metrics.structural_similarity
    gauss = np.exp(-(np.arange(-r, r+1)**2) / (2.0 * sigma**2))
    gauss_i, gauss_j = np.meshgrid(gauss, gauss, indexing="ij")
    gauss = gauss_i * gauss_j
    gauss /= gauss.sum()
    # compute statistics for all patches
    mu1 = cv2.filter2D(im1, ddepth=-1, kernel=gauss)
    mu2 = cv2.filter2D(im2, ddepth=-1, kernel=gauss)
    mu11, mu22, mu12 = mu1 * mu1, mu2 * mu2, mu1 * mu2
    s11 = cv2.filter2D(im1*im1, ddepth=-1, kernel=gauss) - mu11
    s22 = cv2.filter2D(im2*im2, ddepth=-1, kernel=gauss) - mu22
    s12 = cv2.filter2D(im1*im2, ddepth=-1, kernel=gauss) - mu12
    # crop patches
    mu11, mu22, mu12 = mu11[r:-r, r:-r], mu22[r:-r, r:-r], mu12[r:-r, r:-r]
    s11, s22, s12 = s11[r:-r, r:-r], s22[r:-r, r:-r], s12[r:-r, r:-r]
    # ssim formula
    c1, c2 = (0.01 * data_range)**2, (0.03 * data_range)**2
    ssim = ((2.0*mu12 + c1) * (2.0*s12 + c2)) / ((mu11 + mu22 + c1) * (s11 + s22 + c2))
    # average
    if weights is None:
      weights = [1.0 for _ in range(im1.shape[2])]
    weights = np.asarray(weights, dtype=im1.dtype)
    return float((ssim.mean(axis=(0, 1)) * weights).sum() / weights.sum())

[9]:
# Create syntetic 4k images.

shape = (2160, 3840, 3)  # 4k
# shape = (1080, 1920, 3)  # full hd
im1 = np.random.random((2160, 3840, 3))
im2 = 0.8*im1 + 0.2*np.random.random((2160, 3840, 3))
im1, im2 = im1.astype(np.float32), im2.astype(np.float32)
[10]:
time_naive_psnr = min(timeit.repeat(lambda: naive_psnr(im1, im2), repeat=5, number=10)) / 10
time_c_psnr = min(timeit.repeat(lambda: compute_psnr(im1, im2), repeat=5, number=10)) / 10
print(f"PSNR: {1000*time_naive_psnr:.1f}ms vs {1000*time_c_psnr:.1f}ms")
PSNR: 120.8ms vs 4.9ms
[11]:
time_naive_ssim = min(timeit.repeat(lambda: naive_ssim(im1, im2, data_range=1.0), repeat=3, number=10)) / 10
time_c_ssim = min(timeit.repeat(lambda: compute_ssim(im1, im2, data_range=1.0), repeat=3, number=10)) / 10
print(f"SSIM: {1000*time_naive_ssim:.1f}ms vs {1000*time_c_ssim:.1f}ms")
SSIM: 1111.5ms vs 502.3ms