Files

176 lines
5.8 KiB
Python

from __future__ import annotations
import kornia
import pytest
import torch
from core.qa_diff import PSNR_CAP_DB, qa_diff
_G = torch.Generator().manual_seed(0)
def _img(b=1, h=64, w=64) -> torch.Tensor:
return torch.rand(b, h, w, 3, generator=_G) * 0.6 + 0.2
def test_identical() -> None:
a = _img()
diff, m = qa_diff(a, a.clone(), diff_mode="abs")
assert m[0]["ssim"] == pytest.approx(1.0, abs=1e-5)
assert m[0]["psnr"] == PSNR_CAP_DB
assert m[0]["mae"] == 0.0 and m[0]["max_abs"] == 0.0 and m[0]["changed_pct"] == 0.0
assert diff.shape == a.shape and diff.dtype == torch.float32
assert diff.max() == 0.0
def test_constant_offset() -> None:
a = _img()
_, m = qa_diff(a, a + 0.1)
assert m[0]["mae"] == pytest.approx(0.1, abs=1e-5)
assert m[0]["psnr"] == pytest.approx(20.0, abs=0.01)
assert m[0]["max_abs"] == pytest.approx(0.1, abs=1e-5)
assert m[0]["changed_pct"] == pytest.approx(100.0)
def test_ssim_decreases_with_noise() -> None:
a = _img()
lo = qa_diff(a, (a + 0.02 * torch.randn_like(a)).clamp(0, 1))[1][0]["ssim"]
hi = qa_diff(a, (a + 0.2 * torch.randn_like(a)).clamp(0, 1))[1][0]["ssim"]
assert hi < lo < 1.0
def test_mask_restricts_metrics() -> None:
a = _img()
b = a.clone()
b[:, :, 32:] += 0.2
mask = torch.zeros(1, 64, 64)
mask[:, :, :32] = 1.0
_, full = qa_diff(a, b)
_, left = qa_diff(a, b, mask=mask)
_, right = qa_diff(a, b, mask=1.0 - mask)
assert full[0]["mae"] == pytest.approx(0.1, abs=1e-4)
assert left[0]["mae"] == 0.0 and left[0]["changed_pct"] == 0.0
assert right[0]["mae"] == pytest.approx(0.2, abs=1e-4)
assert right[0]["psnr"] < full[0]["psnr"]
def test_empty_mask_neutral() -> None:
a = _img()
_, m = qa_diff(a, a + 0.1, mask=torch.zeros(1, 64, 64))
assert m[0]["ssim"] == 1.0 and m[0]["psnr"] == PSNR_CAP_DB
@pytest.mark.parametrize("mode", ["abs", "heatmap", "ssim_map"])
def test_modes_shape_range(mode: str) -> None:
a = _img()
diff, _ = qa_diff(a, (a + 0.1 * torch.randn_like(a)).clamp(0, 1), diff_mode=mode)
assert diff.shape == a.shape
assert diff.min() >= 0.0 and diff.max() <= 1.0
assert diff.max() > 0.0
def test_heatmap_is_colour_and_ssim_map_grey() -> None:
a = _img()
b = (a + 0.1 * torch.randn_like(a)).clamp(0, 1)
heat, _ = qa_diff(a, b, diff_mode="heatmap")
grey, _ = qa_diff(a, b, diff_mode="ssim_map")
assert not torch.allclose(heat[..., 0], heat[..., 2])
assert torch.equal(grey[..., 0], grey[..., 1])
def test_batch_broadcast() -> None:
a = _img(b=3)
b = _img(b=1)
diff, m = qa_diff(a, b)
assert diff.shape == a.shape and len(m) == 3
_, m_rev = qa_diff(b, a)
assert len(m_rev) == 3
def test_batch_mismatch() -> None:
with pytest.raises(ValueError, match="batch"):
qa_diff(_img(b=2), _img(b=3))
def test_size_mismatch() -> None:
with pytest.raises(ValueError, match="spatial sizes must match"):
qa_diff(_img(), _img(h=32))
def test_validation() -> None:
a = _img()
with pytest.raises(TypeError):
qa_diff("x", a)
with pytest.raises(ValueError, match="odd"):
qa_diff(a, a, window_size=10)
with pytest.raises(ValueError, match="window_size"):
qa_diff(a, a, window_size=33)
with pytest.raises(ValueError, match="amplify"):
qa_diff(a, a, amplify=0.5)
with pytest.raises(ValueError, match="amplify"):
qa_diff(a, a, amplify=101.0)
with pytest.raises(ValueError, match="diff_mode"):
qa_diff(a, a, diff_mode="nope")
with pytest.raises(ValueError, match="shape"):
qa_diff(a[..., :1], a)
with pytest.raises(ValueError, match="mask"):
qa_diff(a, a, mask=torch.ones(1, 32, 32))
_CUDA = pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
@_CUDA
@pytest.mark.parametrize("mode", ["abs", "heatmap", "ssim_map"])
def test_cuda_all_modes_with_mask(mode: str) -> None:
a = _img().cuda()
b = (a + 0.1 * torch.randn_like(a)).clamp(0, 1)
mask = torch.ones(1, 64, 64, device="cuda")
diff, m = qa_diff(a, b, diff_mode=mode, mask=mask)
assert diff.device.type == "cuda" and diff.shape == a.shape
cpu_diff, cpu_m = qa_diff(a.cpu(), b.cpu(), diff_mode=mode, mask=mask.cpu())
assert torch.allclose(diff.cpu(), cpu_diff, atol=1e-3)
assert m[0]["psnr"] == pytest.approx(cpu_m[0]["psnr"], abs=1e-2)
@_CUDA
def test_mixed_devices_follow_image_a() -> None:
a = _img()
b = (a + 0.1).cuda()
diff, m = qa_diff(a, b, diff_mode="heatmap", mask=torch.ones(1, 64, 64, device="cuda"))
assert diff.device == a.device
assert m[0]["mae"] == pytest.approx(0.1, abs=1e-4)
@pytest.mark.parametrize("size", [3, 5, 11])
def test_small_images_clamp_window(size: int) -> None:
a = _img(h=size, w=size)
diff, m = qa_diff(a, (a + 0.05).clamp(0, 1), window_size=11)
assert diff.shape == a.shape
assert 0.0 < m[0]["ssim"] <= 1.0
@pytest.mark.parametrize("hw", [(1, 1), (2, 8), (8, 2)])
def test_too_small_images_raise(hw: tuple[int, int]) -> None:
a = _img(h=hw[0], w=hw[1])
with pytest.raises(ValueError, match="at least 3x3"):
qa_diff(a, a)
def test_ssim_matches_kornia_channel_mean() -> None:
a = _img()
b = (a + 0.1 * torch.randn_like(a)).clamp(0, 1)
ref = kornia.metrics.ssim(a.permute(0, 3, 1, 2), b.permute(0, 3, 1, 2), 7, max_val=1.0).mean()
_, m = qa_diff(a, b, window_size=7)
assert m[0]["ssim"] == pytest.approx(ref.item(), abs=1e-6)
def test_ssim_closed_form_constant_images() -> None:
# Constant images: variances/covariance are 0, so SSIM = (2*ma*mb + C1) / (ma^2 + mb^2 + C1).
c1 = 0.01**2
a = torch.full((1, 16, 16, 3), 0.5)
b = torch.full((1, 16, 16, 3), 0.3)
expected = (2 * 0.5 * 0.3 + c1) / (0.5**2 + 0.3**2 + c1)
_, m = qa_diff(a, b)
assert m[0]["ssim"] == pytest.approx(expected, abs=1e-5)