from PIL import Image
from torchvision import transforms

import cv2
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import requests
from io import BytesIO

class GaussianBlur(object):
    def __call__(self, img):
        sigma = 2
        after = cv2.GaussianBlur(np.asarray(img), (23, 23), sigma)
        return after

class GaussianBlur2(object):
    """blur a single image on CPU"""
    def __init__(self, kernel_size=23):
        radias = kernel_size // 2
        kernel_size = radias * 2 + 1
        self.pad = nn.ReflectionPad2d(radias)
        self.blur_h = nn.Conv2d(3, 3, kernel_size=(kernel_size, 1),
                                stride=1, padding=0, bias=False, groups=3)
        self.blur_v = nn.Conv2d(3, 3, kernel_size=(1, kernel_size),
                                stride=1, padding=0, bias=False, groups=3)
        self.k = kernel_size
        self.r = radias

        self.tensor_to_pil = transforms.ToPILImage()

    def __call__(self, img):
        img = torch.tensor(np.asarray(img)).unsqueeze(0).float()
        img = img.permute(0, 3, 1, 2)

        sigma = 2
        x = np.arange(-self.r, self.r + 1)
        x = np.exp(-np.power(x, 2) / (2 * sigma * sigma))
        x = x / x.sum()
        x = torch.from_numpy(x).view(1, -1).repeat(3, 1)

        self.blur_h.weight.data.copy_(x.view(3, 1, self.k, 1))
        self.blur_v.weight.data.copy_(x.view(3, 1, 1, self.k))

        with torch.no_grad():
            img = self.pad(img)
            img = self.blur_h(img)
            img = self.blur_v(img)
            img = img.round()
            img = img.squeeze()


        img = self.tensor_to_pil((img / 255).float().cpu())

        return img

response = requests.get('https://img.sunset02.com/sites/default/files/styles/marquee_large_2x/public/image/2017/04/main/el-capitan-getty-0517.jpg?itok=vuJS4hXh')
img = Image.open(BytesIO(response.content)).convert('RGB')
cv2img = Image.fromarray(GaussianBlur()(img))
cv2img_tensor = transforms.ToTensor()(cv2img)
torchimg = GaussianBlur2()(img)
torchimg_tensor = transforms.ToTensor()(torchimg)

match = (cv2img_tensor == torchimg_tensor).float()
print(match.sum() / (match.shape[0] * match.shape[1] * match.shape[2]))
print(np.asarray(cv2img)[np.asarray(cv2img) != np.asarray(torchimg)])
print(np.asarray(torchimg)[np.asarray(cv2img) != np.asarray(torchimg)])

>>> tensor(0.9617)
>>> [226 161 173 ...  22  18   3]
>>> [227 162 172 ...  23  19   2]

As far as I know, OpenCV’s GaussianBlur is wrapping sepFilter2D, which is exactly same with the above implementation in pytorch.
However, these two implementation yield ~4% difference in above (image, kernel_size, sigma).
The difference becomes larger worse when the rounding (img = img.round()) is removed (reaches ~50%)

pytorch==1.5.1, opencv-python==4.3.0.36