Benchmarking#

This example benchmarks several 2D downsampling methods over a set of test images. For each image we:

  1. Downsample by an image-specific zoom factor, then upsample back (round-trip).

  2. Measure the runtime of the round-trip (forward + backward).

  3. Compute SNR / MSE / SSIM on a local ROI.

  4. Visualise the results with ROI-aware zooms: first a 2x2 figure showing the original image with its magnified ROI and the SplineOps Antialiasing first-pass image with the mapped ROI, then ROI montages of the original ROI and each method’s first-pass ROI, all magnified with nearest-neighbour.

We compare:

  • SciPy ndimage.zoom (cubic).

  • SplineOps Standard cubic interpolation.

  • SplineOps Cubic Antialiasing (oblique projection).

  • OpenCV INTER_CUBIC.

  • Pillow BICUBIC.

  • scikit-image (resize, cubic).

  • scikit-image (resize, cubic + anti_aliasing).

  • PyTorch (F.interpolate bicubic, CPU).

  • PyTorch (F.interpolate bicubic, antialias=True, CPU).

Notes#

  • All ops run on grayscale images normalized to [0, 1] for metrics.

  • Methods with missing deps are marked “unavailable” in the console and skipped from the ROI montages.

Imports and Configuration#

from __future__ import annotations

import math
import os
import sys
import time
from typing import Dict, List, Tuple, Optional

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as patches
from urllib.request import urlopen
from PIL import Image

# Default storage dtype for comparison (change to np.float64 if desired)
DTYPE = np.float32

# ROI / detail-window configuration
ROI_MAG_TARGET = 256           # target height for nearest-neighbour zoom tiles
ROI_TILE_TITLE_FONTSIZE = 12
ROI_SUPTITLE_FONTSIZE = 14  # currently unused, kept for consistency

# Plot appearance for slide-friendly export
PLOT_FIGSIZE = (14, 7)      # same 2:1 ratio as (10, 5), just larger
PLOT_TITLE_FONTSIZE = 18
PLOT_LABEL_FONTSIZE = 18
PLOT_TICK_FONTSIZE = 18
PLOT_LEGEND_FONTSIZE = 18

# Highlight styles (per method) used everywhere (ROI montages + plots)
HIGHLIGHT_STYLE = {
    "SplineOps Standard cubic": {
        "color": "#C2410C",
        "lw": 3.0,
    },
    "SplineOps Antialiasing cubic": {
        "color": "#BE185D",
        "lw": 3.0,
    },
}

AA_METHOD_LABEL = "SplineOps Antialiasing cubic"
AA_COLOR = HIGHLIGHT_STYLE[AA_METHOD_LABEL]["color"]

def fmt_ms(seconds: float) -> str:
    """Format seconds as a short 'X.X ms' string."""
    return f"{seconds * 1000.0:.1f} ms"

# Benchmark configuration
N_TRIALS = 10

# Optional deps
try:
    import cv2
    _HAS_CV2 = True
    # Undo OpenCV's Qt plugin path override to keep using the system/PyQt plugins
    os.environ.pop("QT_QPA_PLATFORM_PLUGIN_PATH", None)
except Exception:
    _HAS_CV2 = False

try:
    from scipy.ndimage import zoom as _ndi_zoom
    _HAS_SCIPY = True
except Exception:
    _HAS_SCIPY = False

try:
    from skimage.transform import resize as _sk_resize
    from skimage.metrics import structural_similarity as _ssim  # noqa: F401
    _HAS_SKIMAGE = True
except Exception:
    _HAS_SKIMAGE = False
    _ssim = None

try:
    import torch
    import torch.nn.functional as F
    _HAS_TORCH = True
except Exception:
    _HAS_TORCH = False

# SplineOps
try:
    from splineops.resize import resize as sp_resize
    _HAS_SPLINEOPS = True
except Exception as e:
    _HAS_SPLINEOPS = False
    _SPLINEOPS_IMPORT_ERR = str(e)

try:
    from splineops.utils.specs import print_runtime_context
    _HAS_SPECS = True
except Exception:
    print_runtime_context = None
    _HAS_SPECS = False

Kodak Test Set Configuration#

KODAK_BASE = "https://r0k.us/graphics/kodak/kodak"
KODAK_IMAGES = [
    ("kodim05", f"{KODAK_BASE}/kodim05.png"),
    ("kodim07", f"{KODAK_BASE}/kodim07.png"),
    ("kodim14", f"{KODAK_BASE}/kodim14.png"),
    ("kodim15", f"{KODAK_BASE}/kodim15.png"),
    ("kodim19", f"{KODAK_BASE}/kodim19.png"),
    ("kodim22", f"{KODAK_BASE}/kodim22.png"),
    ("kodim23", f"{KODAK_BASE}/kodim23.png"),
]

# Per-image zoom + ROI config
IMAGE_CONFIG: Dict[str, Dict[str, object]] = {
    "kodim05": dict(
        zoom=0.15,
        roi_size_px=256,
        roi_center_frac=(0.75, 0.5),
    ),
    "kodim07": dict(
        zoom=0.15,
        roi_size_px=256,
        roi_center_frac=(0.40, 0.50),
    ),
    "kodim14": dict(
        zoom=0.3,
        roi_size_px=256,
        roi_center_frac=(0.75, 0.75),
    ),
    "kodim15": dict(
        zoom=0.3,
        roi_size_px=256,
        roi_center_frac=(0.30, 0.55),
    ),
    "kodim19": dict(
        zoom=0.2,
        roi_size_px=256,
        roi_center_frac=(0.65, 0.35),
    ),
    "kodim22": dict(
        zoom=0.2,
        roi_size_px=256,
        roi_center_frac=(0.50, 0.25),
    ),
    "kodim23": dict(
        zoom=0.15,
        roi_size_px=256,
        roi_center_frac=(0.40, 0.65),
    ),
}


def _load_kodak_gray(url: str) -> np.ndarray:
    """
    Download a Kodak image, convert to grayscale [0, 1] in DTYPE (float32).
    """
    with urlopen(url, timeout=10) as resp:
        img = Image.open(resp)
    arr = np.asarray(img, dtype=np.float64)

    if arr.ndim == 3 and arr.shape[2] >= 3:
        arr01 = arr / 255.0
        gray = (
            0.2989 * arr01[..., 0]
            + 0.5870 * arr01[..., 1]
            + 0.1140 * arr01[..., 2]
        )
    else:
        vmax = float(arr.max()) or 1.0
        gray = arr / vmax

    return np.clip(gray, 0.0, 1.0).astype(DTYPE)


def _load_kodak_rgb(url: str) -> np.ndarray:
    """
    Download a Kodak image as RGB [0, 1] in DTYPE (float32).
    Used only for color visualizations; metrics remain on grayscale.
    """
    with urlopen(url, timeout=10) as resp:
        img = Image.open(resp).convert("RGB")
    arr = np.asarray(img, dtype=np.float64) / 255.0  # H×W×3 in [0,1]
    return np.clip(arr, 0.0, 1.0).astype(DTYPE, copy=False)

Utilities (Metrics, ROI, Plotting)#

def _snr_db(x: np.ndarray, y: np.ndarray) -> float:
    num = float(np.sum(x * x, dtype=np.float64))
    den = float(np.sum((x - y) ** 2, dtype=np.float64))
    if den == 0.0:
        return float("inf")
    if num == 0.0:
        return -float("inf")
    return 10.0 * math.log10(num / den)


def _roi_rect_from_frac(
    shape: Tuple[int, int],
    roi_size_px: int,
    center_frac: Tuple[float, float],
) -> Tuple[int, int, int, int]:
    """Compute a square ROI inside an image, centred at fractional coordinates."""
    H, W = shape[:2]
    row_frac, col_frac = center_frac

    size = int(min(roi_size_px, H, W))
    if size < 1:
        size = min(H, W)

    center_r = int(round(row_frac * H))
    center_c = int(round(col_frac * W))

    row_top = int(np.clip(center_r - size // 2, 0, H - size))
    col_left = int(np.clip(center_c - size // 2, 0, W - size))

    return row_top, col_left, size, size


def _crop_roi(arr: np.ndarray, rect: Tuple[int, int, int, int]) -> np.ndarray:
    r0, c0, h, w = rect
    return arr[r0 : r0 + h, c0 : c0 + w]


def _nearest_big(roi: np.ndarray, target_h: int = ROI_MAG_TARGET) -> np.ndarray:
    """Enlarge a small ROI with nearest-neighbour so its height is ~target_h."""
    h, w = roi.shape
    mag = max(1, int(round(target_h / max(h, 1))))
    return np.repeat(np.repeat(roi, mag, axis=0), mag, axis=1)


def _diff_normalized(
    orig: np.ndarray,
    rec: np.ndarray,
    *,
    max_abs: Optional[float] = None,
) -> np.ndarray:
    """
    Normalize signed difference (rec - orig) into [0,1] for display.

    0.5 = no difference, >0.5 positive, <0.5 negative.

    If max_abs is provided, it is used as a shared scale (same range across tiles).
    """
    diff = rec.astype(np.float64, copy=False) - orig.astype(np.float64, copy=False)

    if max_abs is None:
        max_abs = float(np.max(np.abs(diff)))
    else:
        max_abs = float(max_abs)

    if max_abs <= 0.0:
        return 0.5 * np.ones_like(diff, dtype=DTYPE)

    norm = 0.5 + 0.5 * diff / max_abs
    norm = np.clip(norm, 0.0, 1.0)
    return norm.astype(DTYPE, copy=False)

def _show_initial_original_vs_aa(
    gray: np.ndarray,
    roi_rect: Tuple[int, int, int, int],
    aa_first: np.ndarray,
    z: float,
    degree_label: str,
) -> None:
    """
    Plot a 2x2 figure:

    Row 1:
      - Original image with red ROI box
      - Magnified original ROI

    Row 2:
      - SplineOps Antialiasing first-pass resized image on white canvas with
        mapped ROI box
      - Magnified mapped ROI from the Antialiasing first-pass
    """
    H, W = gray.shape
    row0, col0, roi_h, roi_w = roi_rect

    roi_orig = _crop_roi(gray, roi_rect)
    roi_orig_big = _nearest_big(roi_orig, ROI_MAG_TARGET)

    H1, W1 = aa_first.shape
    center_r = row0 + roi_h / 2.0
    center_c = col0 + roi_w / 2.0

    roi_h_res = max(1, int(round(roi_h * z)))
    roi_w_res = max(1, int(round(roi_w * z)))

    if roi_h_res > H1 or roi_w_res > W1:
        aa_roi = aa_first
        row_top_res = 0
        col_left_res = 0
        roi_h_res = H1
        roi_w_res = W1
    else:
        center_r_res = int(round(center_r * z))
        center_c_res = int(round(center_c * z))
        row_top_res = int(np.clip(center_r_res - roi_h_res // 2, 0, H1 - roi_h_res))
        col_left_res = int(np.clip(center_c_res - roi_w_res // 2, 0, W1 - roi_w_res))
        aa_roi = aa_first[
            row_top_res : row_top_res + roi_h_res,
            col_left_res : col_left_res + roi_w_res,
        ]

    aa_roi_big = _nearest_big(aa_roi, ROI_MAG_TARGET)

    canvas_aa = np.ones_like(gray, dtype=aa_first.dtype)
    h_copy = min(H, H1)
    w_copy = min(W, W1)
    canvas_aa[:h_copy, :w_copy] = aa_first[:h_copy, :w_copy]

    fig, axes = plt.subplots(2, 2, figsize=(10, 8))

    # Row 1, left: original with ROI box
    ax = axes[0, 0]
    ax.imshow(gray, cmap="gray", interpolation="nearest", aspect="equal")
    rect = patches.Rectangle(
        (col0, row0),
        roi_w,
        roi_h,
        linewidth=2,
        edgecolor="red",
        facecolor="none",
    )
    ax.add_patch(rect)
    ax.set_title(
        f"Original image with ROI ({H}×{W} px)",
        fontsize=ROI_TILE_TITLE_FONTSIZE,
    )
    ax.axis("off")

    # Row 1, right: magnified original ROI
    ax = axes[0, 1]
    ax.imshow(roi_orig_big, cmap="gray", interpolation="nearest", aspect="equal")
    ax.set_title(
        f"Original ROI ({roi_h}×{roi_w} px, NN magnified)",
        fontsize=ROI_TILE_TITLE_FONTSIZE,
    )
    ax.axis("off")

    # Row 2, left: Antialiasing first-pass on canvas with mapped ROI box
    ax = axes[1, 0]
    ax.imshow(canvas_aa, cmap="gray", interpolation="nearest", aspect="equal")
    if row_top_res < h_copy and col_left_res < w_copy:
        box_h = min(roi_h_res, h_copy - row_top_res)
        box_w = min(roi_w_res, w_copy - col_left_res)
        rect_aa = patches.Rectangle(
            (col_left_res, row_top_res),
            box_w,
            box_h,
            linewidth=2,
            edgecolor="red",
            facecolor="none",
        )
        ax.add_patch(rect_aa)

    ax.set_title(
        f"{AA_METHOD_LABEL}\n(zoom ×{z:g}, {H1}×{W1} px)",
        fontsize=ROI_TILE_TITLE_FONTSIZE,
        color=AA_COLOR,
        fontweight="bold",
        multialignment="center",
    )
    ax.axis("off")

    # Row 2, right: magnified Antialiasing ROI
    ax = axes[1, 1]
    ax.imshow(aa_roi_big, cmap="gray", interpolation="nearest", aspect="equal")
    ax.set_title(
        f"Antialiasing ROI ({roi_h_res}×{roi_w_res} px, NN magnified)",
        fontsize=ROI_TILE_TITLE_FONTSIZE,
    )
    ax.axis("off")

    fig.tight_layout()
    plt.show()


def _nearest_big_color(roi: np.ndarray, target_h: int = ROI_MAG_TARGET) -> np.ndarray:
    """
    Enlarge a small color ROI (H×W×3) with nearest-neighbour so its height
    is ~target_h pixels.
    """
    h, w, _ = roi.shape
    mag = max(1, int(round(target_h / max(h, 1))))
    return np.repeat(np.repeat(roi, mag, axis=0), mag, axis=1)


def show_intro_color(
    original_rgb: np.ndarray,
    shrunk_rgb: np.ndarray,
    roi_rect: Tuple[int, int, int, int],
    zoom: float,
    label: str,
    degree_label: str,
) -> None:
    """
    2×2 figure mirroring the benchmarking intro style, but in color.
    """
    H, W, _ = original_rgb.shape
    row0, col0, roi_h, roi_w = roi_rect

    roi_orig = original_rgb[row0:row0 + roi_h, col0:col0 + roi_w, :]
    roi_orig_big = _nearest_big_color(roi_orig, target_h=ROI_MAG_TARGET)

    Hs, Ws, _ = shrunk_rgb.shape
    center_r = row0 + roi_h / 2.0
    center_c = col0 + roi_w / 2.0

    roi_h_res = max(1, int(round(roi_h * zoom)))
    roi_w_res = max(1, int(round(roi_w * zoom)))

    if roi_h_res > Hs or roi_w_res > Ws:
        roi_shrunk = shrunk_rgb
        row_top_res = 0
        col_left_res = 0
        roi_h_res = Hs
        roi_w_res = Ws
    else:
        center_r_res = int(round(center_r * zoom))
        center_c_res = int(round(center_c * zoom))
        row_top_res = int(np.clip(center_r_res - roi_h_res // 2, 0, Hs - roi_h_res))
        col_left_res = int(np.clip(center_c_res - roi_w_res // 2, 0, Ws - roi_w_res))
        roi_shrunk = shrunk_rgb[
            row_top_res:row_top_res + roi_h_res,
            col_left_res:col_left_res + roi_w_res,
            :
        ]

    roi_shrunk_big = _nearest_big_color(roi_shrunk, target_h=ROI_MAG_TARGET)

    canvas = np.ones_like(original_rgb)
    h_copy = min(H, Hs)
    w_copy = min(W, Ws)
    canvas[:h_copy, :w_copy, :] = shrunk_rgb[:h_copy, :w_copy, :]

    fig, axes = plt.subplots(2, 2, figsize=(10, 8))

    ax = axes[0, 0]
    ax.imshow(np.clip(original_rgb, 0.0, 1.0))
    rect = patches.Rectangle(
        (col0, row0),
        roi_w,
        roi_h,
        linewidth=2,
        edgecolor="red",
        facecolor="none",
    )
    ax.add_patch(rect)
    ax.set_title(
        f"Original image with ROI ({H}×{W} px)",
        fontsize=ROI_TILE_TITLE_FONTSIZE,
    )
    ax.axis("off")

    ax = axes[0, 1]
    ax.imshow(np.clip(roi_orig_big, 0.0, 1.0))
    ax.set_title(
        f"Original ROI ({roi_h}×{roi_w} px, NN magnified)",
        fontsize=ROI_TILE_TITLE_FONTSIZE,
    )
    ax.axis("off")

    ax = axes[1, 0]
    ax.imshow(np.clip(canvas, 0.0, 1.0))
    if row_top_res < h_copy and col_left_res < w_copy:
        box_h = min(roi_h_res, h_copy - row_top_res)
        box_w = min(roi_w_res, w_copy - col_left_res)
        rect2 = patches.Rectangle(
            (col_left_res, row_top_res),
            box_w,
            box_h,
            linewidth=2,
            edgecolor="red",
            facecolor="none",
        )
        ax.add_patch(rect2)
    # Bottom-left: resized image on canvas (change title only here)
    if label == "Antialiasing":
        title_kw = dict(
            fontsize=ROI_TILE_TITLE_FONTSIZE,
            fontweight="bold",
            color=AA_COLOR,
            multialignment="center",
        )
        ax.set_title(
            f"{AA_METHOD_LABEL}\n(zoom ×{zoom:g}, {Hs}×{Ws} px)",
            **title_kw,
        )
    ax.axis("off")

    ax = axes[1, 1]
    ax.imshow(np.clip(roi_shrunk_big, 0.0, 1.0))

    if label == "Antialiasing":
        title_kw = dict(fontsize=ROI_TILE_TITLE_FONTSIZE, fontweight="bold")
        if AA_COLOR is not None:
            title_kw["color"] = AA_COLOR
        ax.set_title(
            f"{label} ROI ({roi_h_res}×{roi_w_res} px, NN magnified)",
            **title_kw,
        )
    else:
        ax.set_title(
            f"{label} ROI ({roi_h_res}×{roi_w_res} px, NN magnified)",
            fontsize=ROI_TILE_TITLE_FONTSIZE,
        )

    ax.axis("off")

    fig.tight_layout()
    plt.show()

def _smart_ylim(
    values: np.ndarray,
    *,
    hi_cap: float | None = None,
    lo_cap: float | None = None,
    pad_frac: float = 0.06,
    iqr_k: float = 1.5,
    q_floor: float = 10.0,   # percentile used when min is an outlier
    min_span: float | None = None,
) -> tuple[float, float] | None:
    """
    Robust y-limits for plots.

    - If the minimum is a strong outlier (below Q1 - k*IQR), use q_floor percentile as the lower bound.
    - Otherwise use the true min.
    - Add a small padding.
    - Optionally clamp/cap.
    """
    v = np.asarray(values, dtype=np.float64)
    v = v[np.isfinite(v)]
    if v.size == 0:
        return None

    vmin = float(v.min())
    vmax = float(v.max())

    if vmin == vmax:
        span = float(min_span) if min_span is not None else (1e-3 if vmax <= 1.0 else 1.0)
        lo, hi = vmin - 0.5 * span, vmax + 0.5 * span
    else:
        q1, q3 = np.percentile(v, [25.0, 75.0])
        iqr = float(q3 - q1)

        lo0 = vmin
        if iqr > 0.0:
            low_outlier_thr = float(q1 - iqr_k * iqr)
            if vmin < low_outlier_thr:
                lo0 = float(np.percentile(v, q_floor))

        span = vmax - lo0
        pad = pad_frac * span
        lo, hi = lo0 - pad, vmax + pad

        if min_span is not None and (hi - lo) < float(min_span):
            mid = 0.5 * (hi + lo)
            lo = mid - 0.5 * float(min_span)
            hi = mid + 0.5 * float(min_span)

    if lo_cap is not None:
        lo = max(lo, float(lo_cap))
    if hi_cap is not None:
        hi = min(hi, float(hi_cap))

    if lo >= hi:
        hi = lo + (float(min_span) if min_span is not None else 1e-6)

    return lo, hi

def _highlight_tile(ax, *, color: str, lw: float = 3.0) -> None:
    # Full-axes border, works even with ax.axis("off")
    rect = patches.Rectangle(
        (0, 0), 1, 1,
        transform=ax.transAxes,
        fill=False,
        edgecolor=color,
        linewidth=lw,
        clip_on=False,
    )
    ax.add_patch(rect)

Round-Trip Backends and Time#

def _rt_splineops(
    gray: np.ndarray, z: float, preset: str
) -> Tuple[np.ndarray, np.ndarray, Optional[str]]:
    """SplineOps standard / antialiasing cubic."""
    if not _HAS_SPLINEOPS:
        return gray, gray, f"SplineOps unavailable: {_SPLINEOPS_IMPORT_ERR}"
    try:
        first = sp_resize(gray, zoom_factors=(z, z), method=preset)
        rec   = sp_resize(first, output_size=gray.shape, method=preset)
        first = np.clip(first, 0.0, 1.0).astype(gray.dtype, copy=False)
        rec   = np.clip(rec,   0.0, 1.0).astype(gray.dtype, copy=False)
        return first, rec, None
    except Exception as e:
        return gray, gray, str(e)


def _rt_scipy(
    gray: np.ndarray, z: float
) -> Tuple[np.ndarray, np.ndarray, Optional[str]]:
    """SciPy ndimage.zoom, cubic, reflect boundary."""
    if not _HAS_SCIPY:
        return gray, gray, "SciPy not installed"
    try:
        order = 3
        need_prefilter = True

        first = _ndi_zoom(
            gray,
            (z, z),
            order=order,
            prefilter=need_prefilter,
            mode="reflect",
            grid_mode=False,
        )

        Hz, Wz = first.shape
        back = (gray.shape[0] / Hz, gray.shape[1] / Wz)
        rec = _ndi_zoom(
            first,
            back,
            order=order,
            prefilter=need_prefilter,
            mode="reflect",
            grid_mode=False,
        )

        first = np.clip(first, 0.0, 1.0)
        rec   = np.clip(rec,   0.0, 1.0)

        if rec.shape != gray.shape:
            h = min(rec.shape[0], gray.shape[0])
            w = min(rec.shape[1], gray.shape[1])
            r0 = (rec.shape[0] - h) // 2
            r1 = r0 + h
            c0 = (rec.shape[1] - w) // 2
            c1 = c0 + w
            rc = rec[r0:r1, c0:c1]
            g0 = (gray.shape[0] - h) // 2
            g1 = g0 + h
            g2 = (gray.shape[1] - w) // 2
            g3 = g2 + w
            tmp = np.zeros_like(gray)
            tmp[g0:g1, g2:g3] = rc
            rec = tmp

        return first.astype(gray.dtype, copy=False), rec.astype(gray.dtype, copy=False), None
    except Exception as e:
        return gray, gray, str(e)


def _rt_opencv(
    gray: np.ndarray, z: float
) -> Tuple[np.ndarray, np.ndarray, Optional[str]]:
    """OpenCV INTER_CUBIC."""
    if not _HAS_CV2:
        return gray, gray, "OpenCV not installed"
    try:
        H, W = gray.shape
        H1 = int(round(H * z))
        W1 = int(round(W * z))

        first = cv2.resize(gray, (W1, H1), interpolation=cv2.INTER_CUBIC)
        rec   = cv2.resize(first, (W,  H),  interpolation=cv2.INTER_CUBIC)

        first = np.clip(first, 0.0, 1.0).astype(gray.dtype, copy=False)
        rec   = np.clip(rec,   0.0, 1.0).astype(gray.dtype, copy=False)
        return first, rec, None
    except Exception as e:
        return gray, gray, str(e)


def _rt_pillow(
    gray: np.ndarray, z: float
) -> Tuple[np.ndarray, np.ndarray, Optional[str]]:
    """Pillow BICUBIC on float32 images."""
    try:
        from PIL import Image as _Image

        H, W = gray.shape
        H1 = int(round(H * z))
        W1 = int(round(W * z))

        im = _Image.fromarray(gray.astype(np.float32, copy=False), mode="F")

        first_im = im.resize((W1, H1), resample=_Image.Resampling.BICUBIC)
        rec_im   = first_im.resize((W,  H),  resample=_Image.Resampling.BICUBIC)

        first = np.asarray(first_im, dtype=np.float32)
        rec   = np.asarray(rec_im,   dtype=np.float32)

        first = np.clip(first, 0.0, 1.0).astype(gray.dtype, copy=False)
        rec   = np.clip(rec,   0.0, 1.0).astype(gray.dtype, copy=False)
        return first, rec, None
    except Exception as e:
        return gray, gray, str(e)


def _rt_skimage(
    gray: np.ndarray, z: float
) -> Tuple[np.ndarray, np.ndarray, Optional[str]]:
    """scikit-image resize, cubic, anti_aliasing=False."""
    if not _HAS_SKIMAGE:
        return gray, gray, "scikit-image not installed"
    try:
        H, W = gray.shape
        H1 = int(round(H * z))
        W1 = int(round(W * z))

        first = _sk_resize(
            gray,
            (H1, W1),
            order=3,
            anti_aliasing=False,
            preserve_range=True,
            mode="reflect",
        ).astype(np.float64)
        rec = _sk_resize(
            first,
            (H, W),
            order=3,
            anti_aliasing=False,
            preserve_range=True,
            mode="reflect",
        ).astype(np.float64)

        first = np.clip(first, 0.0, 1.0).astype(gray.dtype, copy=False)
        rec   = np.clip(rec,   0.0, 1.0).astype(gray.dtype, copy=False)
        return first, rec, None
    except Exception as e:
        return gray, gray, str(e)


def _rt_skimage_aa(
    gray: np.ndarray, z: float
) -> Tuple[np.ndarray, np.ndarray, Optional[str]]:
    """scikit-image resize, cubic, anti_aliasing=True on shrink."""
    if not _HAS_SKIMAGE:
        return gray, gray, "scikit-image not installed"
    try:
        H, W = gray.shape
        H1 = int(round(H * z))
        W1 = int(round(W * z))

        first = _sk_resize(
            gray,
            (H1, W1),
            order=3,
            anti_aliasing=True,
            preserve_range=True,
            mode="reflect",
        ).astype(np.float64)
        rec = _sk_resize(
            first,
            (H, W),
            order=3,
            anti_aliasing=False,
            preserve_range=True,
            mode="reflect",
        ).astype(np.float64)

        first = np.clip(first, 0.0, 1.0).astype(gray.dtype, copy=False)
        rec   = np.clip(rec,   0.0, 1.0).astype(gray.dtype, copy=False)
        return first, rec, None
    except Exception as e:
        return gray, gray, str(e)


def _rt_torch(
    gray: np.ndarray, z: float
) -> Tuple[np.ndarray, np.ndarray, Optional[str]]:
    """PyTorch F.interpolate bicubic (antialias=False)."""
    if not _HAS_TORCH:
        return gray, gray, "PyTorch not installed"
    try:
        arr = gray
        if arr.dtype == np.float32:
            t_dtype = torch.float32
        elif arr.dtype == np.float64:
            t_dtype = torch.float64
        else:
            t_dtype = torch.float32
            arr = arr.astype(np.float32, copy=False)

        H, W = arr.shape
        H1 = int(round(H * z))
        W1 = int(round(W * z))

        x = torch.from_numpy(arr).to(t_dtype).unsqueeze(0).unsqueeze(0)

        first_t = F.interpolate(
            x,
            size=(H1, W1),
            mode="bicubic",
            align_corners=False,
            antialias=False,
        )
        rec_t = F.interpolate(
            first_t,
            size=(H, W),
            mode="bicubic",
            align_corners=False,
            antialias=False,
        )

        first = first_t[0, 0].detach().cpu().numpy()
        rec   = rec_t[0, 0].detach().cpu().numpy()

        first = np.clip(first, 0.0, 1.0).astype(gray.dtype, copy=False)
        rec   = np.clip(rec,   0.0, 1.0).astype(gray.dtype, copy=False)
        return first, rec, None
    except Exception as e:
        return gray, gray, str(e)


def _rt_torch_aa(
    gray: np.ndarray, z: float
) -> Tuple[np.ndarray, np.ndarray, Optional[str]]:
    """PyTorch F.interpolate bicubic (antialias=True)."""
    if not _HAS_TORCH:
        return gray, gray, "PyTorch not installed"
    try:
        arr = gray
        if arr.dtype == np.float32:
            t_dtype = torch.float32
        elif arr.dtype == np.float64:
            t_dtype = torch.float64
        else:
            t_dtype = torch.float32
            arr = arr.astype(np.float32, copy=False)

        H, W = arr.shape
        H1 = int(round(H * z))
        W1 = int(round(W * z))

        x = torch.from_numpy(arr).to(t_dtype).unsqueeze(0).unsqueeze(0)

        first_t = F.interpolate(
            x,
            size=(H1, W1),
            mode="bicubic",
            align_corners=False,
            antialias=True,
        )
        rec_t = F.interpolate(
            first_t,
            size=(H, W),
            mode="bicubic",
            align_corners=False,
            antialias=True,
        )

        first = first_t[0, 0].detach().cpu().numpy()
        rec   = rec_t[0, 0].detach().cpu().numpy()

        first = np.clip(first, 0.0, 1.0).astype(gray.dtype, copy=False)
        rec   = np.clip(rec,   0.0, 1.0).astype(gray.dtype, copy=False)
        return first, rec, None
    except Exception as e:
        return gray, gray, str(e)


def _avg_time(rt_fn, repeats: int = N_TRIALS, warmup: bool = True):
    """
    Run `rt_fn()` (which must return (first, rec, err)) `repeats` times.
    Returns (last_first, last_rec, mean_time, std_time, err).
    """
    if warmup:
        try:
            first, rec, err = rt_fn()
            if err is not None:
                return np.array([]), np.array([]), float("nan"), float("nan"), err
        except Exception as e:
            return np.array([]), np.array([]), float("nan"), float("nan"), str(e)

    times: List[float] = []
    last_first: Optional[np.ndarray] = None
    last_rec: Optional[np.ndarray] = None

    for _ in range(max(1, repeats)):
        t0 = time.perf_counter()
        first, rec, err = rt_fn()
        if err is not None:
            return np.array([]), np.array([]), float("nan"), float("nan"), err
        dt = time.perf_counter() - t0
        times.append(dt)
        last_first = first
        last_rec = rec

    t_arr = np.asarray(times, dtype=np.float64)
    mean_t = float(t_arr.mean())
    sd_t = float(t_arr.std(ddof=1 if len(t_arr) > 1 else 0))
    assert last_first is not None and last_rec is not None
    return last_first, last_rec, mean_t, sd_t, None


# Methods and backend keys
BENCH_METHODS: List[Tuple[str, str]] = [
    ("SplineOps Standard cubic",       "spl_standard"),
    ("SplineOps Antialiasing cubic",   "spl_aa"),
    ("OpenCV INTER_CUBIC",             "opencv"),
    ("SciPy cubic",                    "scipy"),
    ("Pillow BICUBIC",                 "pillow"),
    ("scikit-image cubic",             "skimage"),
    ("scikit-image cubic (AA)",        "skimage_aa"),
    ("PyTorch bicubic (CPU)",          "torch"),
    ("PyTorch bicubic (AA, CPU)",      "torch_aa"),
]

# Subsets for ROI main vs AA
MAIN_METHOD_LABELS = [
    "SplineOps Standard cubic",
    "SplineOps Antialiasing cubic",
    "OpenCV INTER_CUBIC",
    "SciPy cubic",
    "scikit-image cubic",
    "PyTorch bicubic (CPU)",
]

AA_METHOD_LABELS = [
    "SplineOps Standard cubic",
    "SplineOps Antialiasing cubic",
    "Pillow BICUBIC",
    "scikit-image cubic (AA)",
    "PyTorch bicubic (AA, CPU)",
]

Benchmarking Helpers#

def benchmark_image(
    img_name: str,
    gray: np.ndarray,
    zoom: float,
    roi_size_px: int,
    roi_center_frac: Tuple[float, float],
    degree_label: str = "Cubic",
) -> Dict[str, object]:
    """
    Run the full round-trip benchmark on one image.

    Returns a dict with:
      - gray, z, roi_rect, roi, degree_label
      - aa_first (for intro plot)
      - roi_tiles (list of (name, tile) for first-pass ROI montages, grayscale)
      - diff_tiles (list of (name, tile) for ROI error montages, grayscale)
      - rows (per-method metrics)
      - diff_max_abs (shared max abs diff used for all diff tiles)
    """
    H, W = gray.shape
    z = zoom

    roi_rect = _roi_rect_from_frac(gray.shape, roi_size_px, roi_center_frac)
    roi = _crop_roi(gray, roi_rect)

    roi_h = roi_rect[2]
    roi_w = roi_rect[3]
    center_r = roi_rect[0] + roi_h / 2.0
    center_c = roi_rect[1] + roi_w / 2.0

    print(
        f"\n=== {img_name} | zoom={z:.3f} | shape={H}×{W} "
        f"| ROI size≈{roi_size_px} px at center_frac={roi_center_frac} ===\n"
    )

    rows: List[Dict[str, object]] = []
    roi_tiles: List[Tuple[str, np.ndarray]] = []
    diff_tiles: List[Tuple[str, np.ndarray]] = []

    # Original ROI tile
    orig_tile = _nearest_big(roi, ROI_MAG_TARGET)
    roi_tiles.append(("Original", orig_tile))

    # Zero-diff baseline (stays at mid-gray)
    diff_zero = 0.5 * np.ones_like(roi, dtype=DTYPE)
    diff_zero_big = _nearest_big(diff_zero, ROI_MAG_TARGET)
    diff_tiles.append(("Original (no diff)", diff_zero_big))

    # Collect per-method recovered ROI for a shared error scale (per image)
    rec_roi_store: List[Tuple[str, np.ndarray]] = []
    diff_max_abs = 0.0

    aa_first_for_plot: Optional[np.ndarray] = None

    header = (
        f"{'Method':<32} {'Time (mean)':>14} {'± SD':>10} "
        f"{'SNR (dB)':>10} {'MSE':>14} {'SSIM':>8}"
    )
    print(header)
    print("-" * len(header))

    for label, backend in BENCH_METHODS:
        if backend == "spl_standard":
            rt_fn = lambda gray=gray, z=z: _rt_splineops(gray, z, "cubic")
        elif backend == "spl_aa":
            rt_fn = lambda gray=gray, z=z: _rt_splineops(gray, z, "cubic-antialiasing")
        elif backend == "opencv":
            rt_fn = lambda gray=gray, z=z: _rt_opencv(gray, z)
        elif backend == "scipy":
            rt_fn = lambda gray=gray, z=z: _rt_scipy(gray, z)
        elif backend == "pillow":
            rt_fn = lambda gray=gray, z=z: _rt_pillow(gray, z)
        elif backend == "skimage":
            rt_fn = lambda gray=gray, z=z: _rt_skimage(gray, z)
        elif backend == "skimage_aa":
            rt_fn = lambda gray=gray, z=z: _rt_skimage_aa(gray, z)
        elif backend == "torch":
            rt_fn = lambda gray=gray, z=z: _rt_torch(gray, z)
        elif backend == "torch_aa":
            rt_fn = lambda gray=gray, z=z: _rt_torch_aa(gray, z)
        else:
            continue

        first, rec, t_mean, t_sd, err = _avg_time(rt_fn, repeats=N_TRIALS, warmup=True)

        if err is not None or first.size == 0 or rec.size == 0:
            print(
                f"{label:<32} {'unavailable':>14} {'':>10} "
                f"{'—':>10} {'—':>14} {'—':>8}"
            )
            rows.append(
                dict(
                    name=label,
                    time=np.nan,
                    sd=np.nan,
                    snr=np.nan,
                    mse=np.nan,
                    ssim=np.nan,
                    err=err,
                )
            )
            continue

        if backend == "spl_aa":
            aa_first_for_plot = first.copy()

        rec_roi = _crop_roi(rec, roi_rect)
        snr = _snr_db(roi, rec_roi)
        mse = float(np.mean((roi - rec_roi) ** 2, dtype=np.float64))

        if _HAS_SKIMAGE and _ssim is not None:
            try:
                dr = float(roi.max() - roi.min())
                if dr <= 0:
                    dr = 1.0
                ssim_val = float(_ssim(roi, rec_roi, data_range=dr))
            except Exception:
                ssim_val = float("nan")
        else:
            ssim_val = float("nan")

        print(
            f"{label:<32} {fmt_ms(t_mean):>14} {fmt_ms(t_sd):>10} "
            f"{snr:>10.2f} {mse:>14.3e} {ssim_val:>8.4f}"
        )

        rows.append(
            dict(
                name=label,
                time=t_mean,
                sd=t_sd,
                snr=snr,
                mse=mse,
                ssim=ssim_val,
                err=None,
            )
        )

        # First-pass ROI tile (in the resized domain)
        H1, W1 = first.shape
        roi_h_res = max(1, int(round(roi_h * z)))
        roi_w_res = max(1, int(round(roi_w * z)))

        if roi_h_res > H1 or roi_w_res > W1:
            first_roi = first
        else:
            center_r_res = int(round(center_r * z))
            center_c_res = int(round(center_c * z))
            row_top_res = int(np.clip(center_r_res - roi_h_res // 2, 0, H1 - roi_h_res))
            col_left_res = int(np.clip(center_c_res - roi_w_res // 2, 0, W1 - roi_w_res))
            first_roi = first[
                row_top_res : row_top_res + roi_h_res,
                col_left_res : col_left_res + roi_w_res,
            ]

        tile = _nearest_big(first_roi, ROI_MAG_TARGET)
        roi_tiles.append((label, tile))

        # Store recovered ROI (copy) for shared-scale diff montage
        rec_roi_store.append((label, rec_roi.astype(DTYPE, copy=True)))

        # Update shared max(|diff|) across all methods for this ROI
        d = rec_roi.astype(np.float64, copy=False) - roi.astype(np.float64, copy=False)
        diff_max_abs = max(diff_max_abs, float(np.max(np.abs(d))))

    # Build diff tiles with ONE shared scale (per image) so all tiles are comparable
    diff_max_abs = max(diff_max_abs, 1e-12)
    for label, rec_roi_m in rec_roi_store:
        diff_roi = _diff_normalized(roi, rec_roi_m, max_abs=diff_max_abs)
        diff_tile = _nearest_big(diff_roi, ROI_MAG_TARGET)
        diff_tiles.append((label, diff_tile))

    return dict(
        img_name=img_name,
        gray=gray,
        z=z,
        roi_rect=roi_rect,
        roi=roi,
        degree_label=degree_label,
        aa_first=aa_first_for_plot,
        roi_tiles=roi_tiles,
        diff_tiles=diff_tiles,
        diff_max_abs=diff_max_abs,
        rows=rows,
    )

def show_intro_from_bench(bench: Dict[str, object]) -> None:
    """2×2 introductory figure for SplineOps AA (grayscale)."""
    aa_first = bench["aa_first"]
    if aa_first is None:
        return
    _show_initial_original_vs_aa(
        gray=bench["gray"],              # type: ignore[arg-type]
        roi_rect=bench["roi_rect"],      # type: ignore[arg-type]
        aa_first=aa_first,               # type: ignore[arg-type]
        z=bench["z"],                    # type: ignore[arg-type]
        degree_label=bench["degree_label"],  # type: ignore[arg-type]
    )


def show_roi_montage_main_from_bench(bench: Dict[str, object]) -> None:
    """
    Grayscale ROI montage (main subset):

      Original +
      SplineOps Standard cubic
      SplineOps Antialiasing cubic
      OpenCV INTER_CUBIC
      SciPy cubic
      scikit-image cubic
      PyTorch bicubic (CPU)
    """
    roi_tiles: List[Tuple[str, np.ndarray]] = bench["roi_tiles"]  # type: ignore[assignment]
    if not roi_tiles:
        return

    tile_map = {name: tile for name, tile in roi_tiles}

    names = ["Original"]
    for lbl in MAIN_METHOD_LABELS:
        if lbl in tile_map:
            names.append(lbl)

    rows, cols = 3, 3
    fig, axes = plt.subplots(rows, cols, figsize=(3.2 * cols, 3.2 * rows))
    axes = np.asarray(axes).reshape(rows, cols)

    for ax in axes.ravel():
        ax.axis("off")

    for idx, name in enumerate(names):
        if idx >= rows * cols:
            break

        tile = tile_map[name]
        r, c = divmod(idx, cols)
        ax = axes[r, c]

        ax.imshow(tile, cmap="gray", interpolation="nearest")

        title_kw = dict(fontsize=ROI_TILE_TITLE_FONTSIZE)

        style = HIGHLIGHT_STYLE.get(name)
        if style is not None:
            c = style.get("color", "tab:blue")
            lw = float(style.get("lw", 3.0))
            title_kw.update(color=c, fontweight="bold")
            _highlight_tile(ax, color=c, lw=lw)

        ax.set_title(name, **title_kw)
        ax.axis("off")

    fig.tight_layout()
    plt.show()

def show_roi_montage_aa_from_bench(bench: Dict[str, object]) -> None:
    """
    Grayscale ROI montage (AA subset):

      Original +
      SplineOps Standard cubic
      SplineOps Antialiasing cubic
      Pillow BICUBIC
      scikit-image cubic (AA)
      PyTorch bicubic (AA, CPU)
    """
    roi_tiles: List[Tuple[str, np.ndarray]] = bench["roi_tiles"]  # type: ignore[assignment]
    if not roi_tiles:
        return

    tile_map = {name: tile for name, tile in roi_tiles}

    names = ["Original"]
    for lbl in AA_METHOD_LABELS:
        if lbl in tile_map:
            names.append(lbl)

    cols = 3
    n_tiles = len(names)
    rows = max(1, (n_tiles + cols - 1) // cols)

    fig, axes = plt.subplots(rows, cols, figsize=(3.2 * cols, 3.2 * rows))
    axes = np.asarray(axes).reshape(rows, cols)

    for ax in axes.ravel():
        ax.axis("off")

    for idx, name in enumerate(names):
        if idx >= rows * cols:
            break

        tile = tile_map[name]
        r, c = divmod(idx, cols)
        ax = axes[r, c]

        ax.imshow(tile, cmap="gray", interpolation="nearest")

        title_kw = dict(fontsize=ROI_TILE_TITLE_FONTSIZE)

        style = HIGHLIGHT_STYLE.get(name)
        if style is not None:
            c = style.get("color", "tab:blue")
            lw = float(style.get("lw", 3.0))
            title_kw.update(color=c, fontweight="bold")
            _highlight_tile(ax, color=c, lw=lw)

        ax.set_title(name, **title_kw)
        ax.axis("off")

    fig.tight_layout()
    plt.show()

def show_error_montage_main_from_bench(bench: Dict[str, object]) -> None:
    """
    Grayscale error montage (main subset):

    (rec - orig) normalized:
      0.5 = 0, <0.5 = negative, >0.5 = positive.
    """
    diff_tiles: List[Tuple[str, np.ndarray]] = bench["diff_tiles"]  # type: ignore[assignment]
    if not diff_tiles:
        return

    tile_map = {name: tile for name, tile in diff_tiles}

    names = ["Original (no diff)"]
    for lbl in MAIN_METHOD_LABELS:
        if lbl in tile_map:
            names.append(lbl)

    rows, cols = 3, 3
    fig, axes = plt.subplots(rows, cols, figsize=(3.2 * cols, 3.2 * rows))
    axes = np.asarray(axes).reshape(rows, cols)

    for ax in axes.ravel():
        ax.axis("off")

    max_tile_slots = rows * cols - 1  # reserve bottom-right for legend
    num_tiles = min(len(names), max_tile_slots)

    for idx in range(num_tiles):
        name = names[idx]
        tile = tile_map[name]
        r, c = divmod(idx, cols)
        ax = axes[r, c]

        ax.imshow(tile, cmap="gray", interpolation="nearest", vmin=0.0, vmax=1.0)

        title_kw = dict(fontsize=ROI_TILE_TITLE_FONTSIZE)

        style = HIGHLIGHT_STYLE.get(name)
        if style is not None:
            c = style.get("color", "tab:blue")
            lw = float(style.get("lw", 3.0))
            title_kw.update(color=c, fontweight="bold")
            _highlight_tile(ax, color=c, lw=lw)

        ax.set_title(name, **title_kw)
        ax.axis("off")

    # Legend (unchanged)
    ax_leg = axes[-1, -1]
    ax_leg.axis("off")

    H_leg = ROI_MAG_TARGET
    W_leg = 32
    y = np.linspace(1.0, 0.0, H_leg, dtype=np.float32)
    legend_img = np.repeat(y[:, None], W_leg, axis=1)

    ax_leg.imshow(legend_img, cmap="gray", vmin=0.0, vmax=1.0, aspect="auto")
    ax_leg.set_title("Diff legend", fontsize=ROI_TILE_TITLE_FONTSIZE, pad=4)

    ax_leg.text(1.05, 0.05, "-1", transform=ax_leg.transAxes,
                fontsize=8, va="bottom", ha="left")
    ax_leg.text(1.05, 0.50, "0 (no diff)", transform=ax_leg.transAxes,
                fontsize=8, va="center", ha="left")
    ax_leg.text(1.05, 0.95, "+1", transform=ax_leg.transAxes,
                fontsize=8, va="top", ha="left")

    fig.suptitle("Normalized signed difference in ROI", fontsize=ROI_SUPTITLE_FONTSIZE)
    fig.tight_layout(rect=[0, 0, 1, 0.95])
    plt.show()

def show_error_montage_aa_from_bench(bench: Dict[str, object]) -> None:
    """
    Grayscale error montage (AA subset):

    (rec - orig) normalized:
      0.5 = 0, <0.5 = negative, >0.5 = positive.
    """
    diff_tiles: List[Tuple[str, np.ndarray]] = bench["diff_tiles"]  # type: ignore[assignment]
    if not diff_tiles:
        return

    tile_map = {name: tile for name, tile in diff_tiles}

    names = ["Original (no diff)"]
    for lbl in AA_METHOD_LABELS:
        if lbl in tile_map:
            names.append(lbl)

    rows, cols = 3, 3
    fig, axes = plt.subplots(rows, cols, figsize=(3.2 * cols, 3.2 * rows))
    axes = np.asarray(axes).reshape(rows, cols)

    for ax in axes.ravel():
        ax.axis("off")

    max_tile_slots = rows * cols - 1
    num_tiles = min(len(names), max_tile_slots)

    for idx in range(num_tiles):
        name = names[idx]
        tile = tile_map[name]
        r, c = divmod(idx, cols)
        ax = axes[r, c]

        ax.imshow(tile, cmap="gray", interpolation="nearest", vmin=0.0, vmax=1.0)

        title_kw = dict(fontsize=ROI_TILE_TITLE_FONTSIZE)

        style = HIGHLIGHT_STYLE.get(name)
        if style is not None:
            c = style.get("color", "tab:blue")
            lw = float(style.get("lw", 3.0))
            title_kw.update(color=c, fontweight="bold")
            _highlight_tile(ax, color=c, lw=lw)

        ax.set_title(name, **title_kw)
        ax.axis("off")

    # Legend (unchanged)
    ax_leg = axes[-1, -1]
    ax_leg.axis("off")

    H_leg = ROI_MAG_TARGET
    W_leg = 32
    y = np.linspace(1.0, 0.0, H_leg, dtype=np.float32)
    legend_img = np.repeat(y[:, None], W_leg, axis=1)

    ax_leg.imshow(legend_img, cmap="gray", vmin=0.0, vmax=1.0, aspect="auto")
    ax_leg.set_title("Diff legend", fontsize=ROI_TILE_TITLE_FONTSIZE, pad=4)

    ax_leg.text(1.05, 0.05, "-1", transform=ax_leg.transAxes,
                fontsize=8, va="bottom", ha="left")
    ax_leg.text(1.05, 0.50, "0 (no diff)", transform=ax_leg.transAxes,
                fontsize=8, va="center", ha="left")
    ax_leg.text(1.05, 0.95, "+1", transform=ax_leg.transAxes,
                fontsize=8, va="top", ha="left")

    fig.suptitle("Normalized signed difference in ROI", fontsize=ROI_SUPTITLE_FONTSIZE)
    fig.tight_layout(rect=[0, 0, 1, 0.95])
    plt.show()

def show_timing_plot_from_bench(bench: Dict[str, object]) -> None:
    """Horizontal bar chart of round-trip timing per method."""
    rows: List[Dict[str, object]] = bench["rows"]  # type: ignore[assignment]
    if not rows:
        return

    gray = bench["gray"]  # type: ignore[index]
    H, W = gray.shape
    z = float(bench["z"])
    degree_label = str(bench["degree_label"])

    valid = [r for r in rows if np.isfinite(r.get("time", np.nan))]
    if not valid:
        return

    names = [r["name"] for r in valid]
    times = np.array([r["time"] for r in valid], dtype=np.float64)
    sds   = np.array([r["sd"]   for r in valid], dtype=np.float64)

    order = np.argsort(times)
    names = [names[i] for i in order]
    times = times[order]
    sds   = sds[order]

    fig, ax = plt.subplots(figsize=PLOT_FIGSIZE)
    y = np.arange(len(names))

    bars = ax.barh(y, times, xerr=sds, alpha=0.8)

    ax.set_yticks(y)
    ax.set_yticklabels(names, fontsize=PLOT_TICK_FONTSIZE)
    ax.tick_params(axis="x", labelsize=PLOT_TICK_FONTSIZE)

    ax.set_xlabel(
        f"Round-trip time (s) mean ± sd over {N_TRIALS} runs",
        fontsize=PLOT_LABEL_FONTSIZE,
    )
    ax.set_title(
        f"Timing vs Method (H×W = {H}×{W}, zoom ×{z:g}, degree={degree_label})",
        fontsize=PLOT_TITLE_FONTSIZE,
    )
    ax.grid(axis="x", alpha=0.3)

    # --- Color the METHOD NAMES (yticks) + outline highlighted bars ---
    for tick, name, bar in zip(ax.get_yticklabels(), names, bars.patches):
        style = HIGHLIGHT_STYLE.get(name)
        if style is not None:
            c = style.get("color", "tab:blue")
            lw = float(style.get("lw", 3.0))
            tick.set_color(c)
            tick.set_fontweight("bold")
            # Optional: outline the bar too (nice but not required)
            bar.set_edgecolor(c)
            bar.set_linewidth(lw)

    fig.tight_layout()
    plt.show()

def show_snr_ssim_plot_from_bench(bench: Dict[str, object]) -> None:
    """Combined SNR/SSIM bar chart per method."""
    if not (_HAS_SKIMAGE and _ssim is not None):
        return

    rows: List[Dict[str, object]] = bench["rows"]  # type: ignore[assignment]
    if not rows:
        return

    gray = bench["gray"]  # type: ignore[index]
    H, W = gray.shape
    z = float(bench["z"])
    degree_label = str(bench["degree_label"])

    valid = [
        r for r in rows
        if np.isfinite(r.get("snr", np.nan)) and np.isfinite(r.get("ssim", np.nan))
    ]
    if not valid:
        return

    names = [r["name"] for r in valid]
    snrs  = np.array([r["snr"]  for r in valid], dtype=np.float64)
    ssims = np.array([r["ssim"] for r in valid], dtype=np.float64)

    order = np.argsort(-snrs)
    names = [names[i] for i in order]
    snrs  = snrs[order]
    ssims = ssims[order]

    x = np.arange(len(names))
    width = 0.4

    fig, ax1 = plt.subplots(figsize=PLOT_FIGSIZE)

    snr_color  = "tab:blue"
    ssim_color = "tab:green"

    snr_bars = ax1.bar(
        x - width / 2,
        snrs,
        width,
        label="SNR (dB)",
        alpha=0.85,
        color=snr_color,
    )
    ax1.set_ylabel("SNR (dB)", color=snr_color, fontsize=PLOT_LABEL_FONTSIZE)
    ax1.tick_params(axis="y", labelcolor=snr_color, labelsize=PLOT_TICK_FONTSIZE)

    ax1.set_xticks(x)
    ax1.set_xticklabels(
        names,
        rotation=30,
        ha="right",
        fontsize=PLOT_TICK_FONTSIZE,
    )

    ax1.grid(axis="y", alpha=0.3)

    ax2 = ax1.twinx()
    ssim_bars = ax2.bar(
        x + width / 2,
        ssims,
        width,
        label="SSIM",
        alpha=0.6,
        color=ssim_color,
    )
    ax2.set_ylabel("SSIM", color=ssim_color, fontsize=PLOT_LABEL_FONTSIZE)
    ax2.tick_params(axis="y", labelcolor=ssim_color, labelsize=PLOT_TICK_FONTSIZE)

    roi_h, roi_w = bench["roi_rect"][2], bench["roi_rect"][3]  # type: ignore[index]

    ax1.set_title(
        f"SNR / SSIM vs Method (ROI = {roi_h}×{roi_w} px, zoom ×{z:g}, degree={degree_label})",
        fontsize=PLOT_TITLE_FONTSIZE,
    )

    handles = [snr_bars[0], ssim_bars[0]]
    labels  = ["SNR (dB)", "SSIM"]
    fig.legend(
        handles,
        labels,
        loc="upper right",
        bbox_to_anchor=(1, 1),
        fontsize=PLOT_LEGEND_FONTSIZE,
    )

    # --- Highlight xtick labels (SplineOps methods) ---
    for tick, name in zip(ax1.get_xticklabels(), names):
        style = HIGHLIGHT_STYLE.get(name)
        if style is not None:
            col = style.get("color", "tab:blue")
            tick.set_color(col)
            tick.set_fontweight("bold")

    # --- Outline BOTH bars (now safe because ssim_bars exists) ---
    for i, name in enumerate(names):
        style = HIGHLIGHT_STYLE.get(name)
        if style is not None:
            col = style.get("color", "tab:blue")
            lw = float(style.get("lw", 3.0))
            snr_bars.patches[i].set_edgecolor(col)
            snr_bars.patches[i].set_linewidth(lw)
            ssim_bars.patches[i].set_edgecolor(col)
            ssim_bars.patches[i].set_linewidth(lw)

    # --- Smart truncated y-limits (robust) ---
    snr_lim = _smart_ylim(snrs, pad_frac=0.06, min_span=1.0)  # dB
    if snr_lim is not None:
        ax1.set_ylim(*snr_lim)

    ssim_lim = _smart_ylim(ssims, lo_cap=0.0, hi_cap=1.0, pad_frac=0.02, min_span=0.02)
    if ssim_lim is not None:
        ax2.set_ylim(*ssim_lim)

    fig.tight_layout()
    plt.show()

Color ROI Montage Helpers#

def _first_pass_color_for_backend(
    backend: str,
    rgb: np.ndarray,
    z: float,
) -> Optional[np.ndarray]:
    """
    First-pass color resized image for a given backend, for color ROIs only.
    """
    H, W, C = rgb.shape
    H1 = int(round(H * z))
    W1 = int(round(W * z))
    if H1 < 1 or W1 < 1:
        return None

    try:
        if backend in ("spl_standard", "spl_aa"):
            if not _HAS_SPLINEOPS:
                return None
            method = "cubic" if backend == "spl_standard" else "cubic-antialiasing"
            zoom_hw = (z, z)
            channels = []
            for c in range(C):
                ch = sp_resize(
                    rgb[..., c],
                    zoom_factors=zoom_hw,
                    method=method,
                )
                channels.append(ch)
            first = np.stack(channels, axis=-1)

        elif backend == "scipy":
            if not _HAS_SCIPY:
                return None
            channels = []
            for c in range(C):
                ch = _ndi_zoom(
                    rgb[..., c],
                    (z, z),
                    order=3,
                    prefilter=True,
                    mode="reflect",
                    grid_mode=False,
                )
                channels.append(ch)
            first = np.stack(channels, axis=-1)

        elif backend == "opencv":
            if not _HAS_CV2:
                return None
            arr = rgb.astype(np.float32, copy=False)
            first = cv2.resize(arr, (W1, H1), interpolation=cv2.INTER_CUBIC)

        elif backend == "pillow":
            from PIL import Image as _Image
            arr_uint8 = (np.clip(rgb, 0.0, 1.0) * 255).astype(np.uint8)
            im = _Image.fromarray(arr_uint8, mode="RGB")
            first_im = im.resize((W1, H1), resample=_Image.Resampling.BICUBIC)
            first = np.asarray(first_im, dtype=np.float32) / 255.0

        elif backend == "skimage":
            if not _HAS_SKIMAGE:
                return None
            first = _sk_resize(
                rgb,
                (H1, W1, C),
                order=3,
                anti_aliasing=False,
                preserve_range=True,
                mode="reflect",
            ).astype(np.float32)

        elif backend == "skimage_aa":
            if not _HAS_SKIMAGE:
                return None
            first = _sk_resize(
                rgb,
                (H1, W1, C),
                order=3,
                anti_aliasing=True,
                preserve_range=True,
                mode="reflect",
            ).astype(np.float32)

        elif backend == "torch":
            if not _HAS_TORCH:
                return None
            arr = rgb
            if arr.dtype == np.float32:
                t_dtype = torch.float32
            elif arr.dtype == np.float64:
                t_dtype = torch.float64
            else:
                t_dtype = torch.float32
                arr = arr.astype(np.float32, copy=False)
            x = torch.from_numpy(arr).to(t_dtype).permute(2, 0, 1).unsqueeze(0)
            first_t = F.interpolate(
                x,
                size=(H1, W1),
                mode="bicubic",
                align_corners=False,
                antialias=False,
            )
            first = first_t[0].permute(1, 2, 0).detach().cpu().numpy()

        elif backend == "torch_aa":
            if not _HAS_TORCH:
                return None
            arr = rgb
            if arr.dtype == np.float32:
                t_dtype = torch.float32
            elif arr.dtype == np.float64:
                t_dtype = torch.float64
            else:
                t_dtype = torch.float32
                arr = arr.astype(np.float32, copy=False)
            x = torch.from_numpy(arr).to(t_dtype).permute(2, 0, 1).unsqueeze(0)
            first_t = F.interpolate(
                x,
                size=(H1, W1),
                mode="bicubic",
                align_corners=False,
                antialias=True,
            )
            first = first_t[0].permute(1, 2, 0).detach().cpu().numpy()

        else:
            return None

        return np.clip(first, 0.0, 1.0).astype(DTYPE, copy=False)
    except Exception:
        return None


def show_roi_montage_color_main_from_bench(
    bench: Dict[str, object],
    orig_rgb: np.ndarray,
) -> None:
    """Color ROI montage for the main subset of methods."""
    roi_rect = bench["roi_rect"]
    z = float(bench["z"])
    row0, col0, roi_h, roi_w = roi_rect
    roi_orig = orig_rgb[row0:row0 + roi_h, col0:col0 + roi_w, :]
    orig_tile = _nearest_big_color(roi_orig, ROI_MAG_TARGET)

    tiles: List[Tuple[str, np.ndarray]] = [("Original (color)", orig_tile)]

    center_r = row0 + roi_h / 2.0
    center_c = col0 + roi_w / 2.0

    subset = [
        ("SplineOps Standard cubic",     "spl_standard"),
        ("SplineOps Antialiasing cubic", "spl_aa"),
        ("OpenCV INTER_CUBIC",           "opencv"),
        ("SciPy cubic",                  "scipy"),
        ("scikit-image cubic",           "skimage"),
        ("PyTorch bicubic (CPU)",        "torch"),
    ]

    for label, backend in subset:
        first_color = _first_pass_color_for_backend(backend, orig_rgb, z)
        if first_color is None:
            continue

        H1, W1, _ = first_color.shape
        roi_h_res = max(1, int(round(roi_h * z)))
        roi_w_res = max(1, int(round(roi_w * z)))

        if roi_h_res > H1 or roi_w_res > W1:
            roi_first = first_color
        else:
            center_r_res = int(round(center_r * z))
            center_c_res = int(round(center_c * z))
            row_top_res = int(np.clip(center_r_res - roi_h_res // 2, 0, H1 - roi_h_res))
            col_left_res = int(np.clip(center_c_res - roi_w_res // 2, 0, W1 - roi_w_res))
            roi_first = first_color[
                row_top_res : row_top_res + roi_h_res,
                col_left_res : col_left_res + roi_w_res,
                :
            ]

        tile = _nearest_big_color(roi_first, ROI_MAG_TARGET)
        tiles.append((label, tile))

    rows, cols = 3, 3
    fig, axes = plt.subplots(rows, cols, figsize=(3.2 * cols, 3.2 * rows))
    axes = np.asarray(axes).reshape(rows, cols)

    for ax in axes.ravel():
        ax.axis("off")

    for idx, (name, tile) in enumerate(tiles):
        if idx >= rows * cols:
            break
        r, c = divmod(idx, cols)
        ax = axes[r, c]

        ax.imshow(np.clip(tile, 0.0, 1.0))

        title_kw = dict(fontsize=ROI_TILE_TITLE_FONTSIZE)

        style = HIGHLIGHT_STYLE.get(name)
        if style is not None:
            c = style.get("color", "tab:blue")
            lw = float(style.get("lw", 3.0))
            title_kw.update(color=c, fontweight="bold")
            _highlight_tile(ax, color=c, lw=lw)

        ax.set_title(name, **title_kw)
        ax.axis("off")

    fig.tight_layout()
    plt.show()


def show_roi_montage_color_aa_from_bench(
    bench: Dict[str, object],
    orig_rgb: np.ndarray,
) -> None:
    """Color ROI montage for AA / smoothing subset."""
    roi_rect = bench["roi_rect"]
    z = float(bench["z"])
    row0, col0, roi_h, roi_w = roi_rect
    roi_orig = orig_rgb[row0:row0 + roi_h, col0:col0 + roi_w, :]
    orig_tile = _nearest_big_color(roi_orig, ROI_MAG_TARGET)

    tiles: List[Tuple[str, np.ndarray]] = [("Original (color)", orig_tile)]

    center_r = row0 + roi_h / 2.0
    center_c = col0 + roi_w / 2.0

    subset = [
        ("SplineOps Standard cubic",     "spl_standard"),
        ("SplineOps Antialiasing cubic", "spl_aa"),
        ("Pillow BICUBIC",               "pillow"),
        ("scikit-image cubic (AA)",      "skimage_aa"),
        ("PyTorch bicubic (AA, CPU)",    "torch_aa"),
    ]

    for label, backend in subset:
        first_color = _first_pass_color_for_backend(backend, orig_rgb, z)
        if first_color is None:
            continue

        H1, W1, _ = first_color.shape
        roi_h_res = max(1, int(round(roi_h * z)))
        roi_w_res = max(1, int(round(roi_w * z)))

        if roi_h_res > H1 or roi_w_res > W1:
            roi_first = first_color
        else:
            center_r_res = int(round(center_r * z))
            center_c_res = int(round(center_c * z))
            row_top_res = int(np.clip(center_r_res - roi_h_res // 2, 0, H1 - roi_h_res))
            col_left_res = int(np.clip(center_c_res - roi_w_res // 2, 0, W1 - roi_w_res))
            roi_first = first_color[
                row_top_res : row_top_res + roi_h_res,
                col_left_res : col_left_res + roi_w_res,
                :
            ]

        tile = _nearest_big_color(roi_first, ROI_MAG_TARGET)
        tiles.append((label, tile))

    cols = 3
    n_tiles = len(tiles)
    rows = max(1, (n_tiles + cols - 1) // cols)

    fig, axes = plt.subplots(rows, cols, figsize=(3.2 * cols, 3.2 * rows))
    axes = np.asarray(axes).reshape(rows, cols)

    for ax in axes.ravel():
        ax.axis("off")

    for idx, (name, tile) in enumerate(tiles):
        if idx >= rows * cols:
            break
        r, c = divmod(idx, cols)
        ax = axes[r, c]

        ax.imshow(np.clip(tile, 0.0, 1.0))

        title_kw = dict(fontsize=ROI_TILE_TITLE_FONTSIZE)

        style = HIGHLIGHT_STYLE.get(name)
        if style is not None:
            c = style.get("color", "tab:blue")
            lw = float(style.get("lw", 3.0))
            title_kw.update(color=c, fontweight="bold")
            _highlight_tile(ax, color=c, lw=lw)

        ax.set_title(name, **title_kw)
        ax.axis("off")

    fig.tight_layout()
    plt.show()

Load All Images#

orig_images: Dict[str, np.ndarray] = {}
orig_images_rgb: Dict[str, np.ndarray] = {}

for name, url in KODAK_IMAGES:
    gray = _load_kodak_gray(url)
    orig_images[name] = gray

    rgb = _load_kodak_rgb(url)
    orig_images_rgb[name] = rgb
    print(f"Loaded {name} from {url}  |  gray shape={gray.shape}, rgb shape={rgb.shape}")

print("\nTimings averaged over "
      f"{N_TRIALS} runs per method (1 warm-up run not counted).\n")

# Small helper for color intro using SplineOps antialiasing
def _color_intro_for_image(
    img_name: str,
    bench: Dict[str, object],
) -> None:
    if not _HAS_SPLINEOPS:
        show_intro_from_bench(bench)
        return

    rgb = orig_images_rgb[img_name]
    roi_rect = bench["roi_rect"]
    z = float(bench["z"])

    zoom_hw = (z, z)
    channels = []
    for c in range(rgb.shape[2]):
        ch = sp_resize(
            rgb[..., c],
            zoom_factors=zoom_hw,
            method="cubic-antialiasing",
        )
        channels.append(ch)
    aa_rgb = np.stack(channels, axis=-1)

    show_intro_color(
        original_rgb=rgb,
        shrunk_rgb=aa_rgb,
        roi_rect=roi_rect,
        zoom=z,
        label="Antialiasing",
        degree_label=bench["degree_label"],  # type: ignore[arg-type]
    )
Loaded kodim05 from https://r0k.us/graphics/kodak/kodak/kodim05.png  |  gray shape=(512, 768), rgb shape=(512, 768, 3)
Loaded kodim07 from https://r0k.us/graphics/kodak/kodak/kodim07.png  |  gray shape=(512, 768), rgb shape=(512, 768, 3)
Loaded kodim14 from https://r0k.us/graphics/kodak/kodak/kodim14.png  |  gray shape=(512, 768), rgb shape=(512, 768, 3)
Loaded kodim15 from https://r0k.us/graphics/kodak/kodak/kodim15.png  |  gray shape=(512, 768), rgb shape=(512, 768, 3)
Loaded kodim19 from https://r0k.us/graphics/kodak/kodak/kodim19.png  |  gray shape=(768, 512), rgb shape=(768, 512, 3)
Loaded kodim22 from https://r0k.us/graphics/kodak/kodak/kodim22.png  |  gray shape=(512, 768), rgb shape=(512, 768, 3)
Loaded kodim23 from https://r0k.us/graphics/kodak/kodak/kodim23.png  |  gray shape=(512, 768), rgb shape=(512, 768, 3)

Timings averaged over 10 runs per method (1 warm-up run not counted).

Image: kodim05#

img_name = "kodim05"
img_orig = orig_images[img_name]
cfg = IMAGE_CONFIG[img_name]
zoom = float(cfg["zoom"])
roi_size_px = int(cfg["roi_size_px"])
roi_center_frac = tuple(map(float, cfg["roi_center_frac"]))  # type: ignore[arg-type]

bench_kodim05 = benchmark_image(
    img_name=img_name,
    gray=img_orig,
    zoom=zoom,
    roi_size_px=roi_size_px,
    roi_center_frac=roi_center_frac,
    degree_label="Cubic",
)
=== kodim05 | zoom=0.150 | shape=512×768 | ROI size≈256 px at center_frac=(0.75, 0.5) ===

Method                              Time (mean)       ± SD   SNR (dB)            MSE     SSIM
---------------------------------------------------------------------------------------------
SplineOps Standard cubic                 2.6 ms     0.0 ms       7.29      1.768e-02   0.3098
SplineOps Antialiasing cubic             4.8 ms     0.0 ms       9.58      1.045e-02   0.3682
OpenCV INTER_CUBIC                       0.4 ms     0.0 ms       7.53      1.672e-02   0.3285
SciPy cubic                             31.3 ms     0.3 ms       7.36      1.740e-02   0.3106
Pillow BICUBIC                           2.9 ms     0.1 ms       9.35      1.101e-02   0.3368
scikit-image cubic                      32.1 ms     0.3 ms       7.52      1.678e-02   0.3233
scikit-image cubic (AA)                 39.5 ms     0.1 ms       9.17      1.147e-02   0.3185
PyTorch bicubic (CPU)                    2.3 ms     0.0 ms       7.53      1.672e-02   0.3285
PyTorch bicubic (AA, CPU)                1.3 ms     0.1 ms       9.35      1.101e-02   0.3368

Original and Resized#

_color_intro_for_image(img_name, bench_kodim05)
Original image with ROI (512×768 px), Original ROI (256×256 px, NN magnified), SplineOps Antialiasing cubic (zoom ×0.15, 77×115 px), Antialiasing ROI (38×38 px, NN magnified)

ROI Comparison#

show_roi_montage_main_from_bench(bench_kodim05)
Original, SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU)

ROI Comparison Error#

show_error_montage_main_from_bench(bench_kodim05)
Normalized signed difference in ROI, Original (no diff), SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU), Diff legend

ROI Comparison (Antialiased)#

show_roi_montage_aa_from_bench(bench_kodim05)
Original, SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU)

ROI Comparison Error (Antialiased)#

show_error_montage_aa_from_bench(bench_kodim05)
Normalized signed difference in ROI, Original (no diff), SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU), Diff legend

ROI Color Comparison#

show_roi_montage_color_main_from_bench(bench_kodim05, orig_images_rgb[img_name])
Original (color), SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU)

ROI Color Comparison (Antialiased)#

show_roi_montage_color_aa_from_bench(bench_kodim05, orig_images_rgb[img_name])
Original (color), SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU)

Timing Comparison#

show_timing_plot_from_bench(bench_kodim05)
Timing vs Method (H×W = 512×768, zoom ×0.15, degree=Cubic)

SNR/SSIM Comparison#

show_snr_ssim_plot_from_bench(bench_kodim05)
SNR / SSIM vs Method (ROI = 256×256 px, zoom ×0.15, degree=Cubic)

Image: kodim07#

img_name = "kodim07"
img_orig = orig_images[img_name]
cfg = IMAGE_CONFIG[img_name]
zoom = float(cfg["zoom"])
roi_size_px = int(cfg["roi_size_px"])
roi_center_frac = tuple(map(float, cfg["roi_center_frac"]))  # type: ignore[arg-type]

bench_kodim07 = benchmark_image(
    img_name=img_name,
    gray=img_orig,
    zoom=zoom,
    roi_size_px=roi_size_px,
    roi_center_frac=roi_center_frac,
    degree_label="Cubic",
)
=== kodim07 | zoom=0.150 | shape=512×768 | ROI size≈256 px at center_frac=(0.4, 0.5) ===

Method                              Time (mean)       ± SD   SNR (dB)            MSE     SSIM
---------------------------------------------------------------------------------------------
SplineOps Standard cubic                 2.6 ms     0.0 ms      13.48      9.393e-03   0.4988
SplineOps Antialiasing cubic             4.7 ms     0.0 ms      15.71      5.618e-03   0.5280
OpenCV INTER_CUBIC                       0.4 ms     0.0 ms      13.52      9.304e-03   0.5022
SciPy cubic                             31.0 ms     0.1 ms      13.48      9.393e-03   0.4988
Pillow BICUBIC                           2.8 ms     0.0 ms      15.51      5.882e-03   0.5091
scikit-image cubic                      31.9 ms     0.1 ms      13.49      9.372e-03   0.4960
scikit-image cubic (AA)                 39.4 ms     0.1 ms      15.29      6.191e-03   0.4948
PyTorch bicubic (CPU)                    3.1 ms     1.1 ms      13.52      9.304e-03   0.5022
PyTorch bicubic (AA, CPU)                1.3 ms     0.0 ms      15.51      5.882e-03   0.5091

Original and Resized#

_color_intro_for_image(img_name, bench_kodim07)
Original image with ROI (512×768 px), Original ROI (256×256 px, NN magnified), SplineOps Antialiasing cubic (zoom ×0.15, 77×115 px), Antialiasing ROI (38×38 px, NN magnified)

ROI Comparison#

show_roi_montage_main_from_bench(bench_kodim07)
Original, SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU)

ROI Comparison Error#

show_error_montage_main_from_bench(bench_kodim07)
Normalized signed difference in ROI, Original (no diff), SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU), Diff legend

ROI Comparison (Antialiased)#

show_roi_montage_aa_from_bench(bench_kodim07)
Original, SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU)

ROI Comparison Error (Antialiased)#

show_error_montage_aa_from_bench(bench_kodim07)
Normalized signed difference in ROI, Original (no diff), SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU), Diff legend

ROI Color Comparison#

show_roi_montage_color_main_from_bench(bench_kodim07, orig_images_rgb[img_name])
Original (color), SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU)

ROI Color Comparison (Antialiased)#

show_roi_montage_color_aa_from_bench(bench_kodim07, orig_images_rgb[img_name])
Original (color), SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU)

Timing Comparison#

show_timing_plot_from_bench(bench_kodim07)
Timing vs Method (H×W = 512×768, zoom ×0.15, degree=Cubic)

SNR/SSIM Comparison#

show_snr_ssim_plot_from_bench(bench_kodim07)
SNR / SSIM vs Method (ROI = 256×256 px, zoom ×0.15, degree=Cubic)

Image: kodim14#

img_name = "kodim14"
img_orig = orig_images[img_name]
cfg = IMAGE_CONFIG[img_name]
zoom = float(cfg["zoom"])
roi_size_px = int(cfg["roi_size_px"])
roi_center_frac = tuple(map(float, cfg["roi_center_frac"]))  # type: ignore[arg-type]

bench_kodim14 = benchmark_image(
    img_name=img_name,
    gray=img_orig,
    zoom=zoom,
    roi_size_px=roi_size_px,
    roi_center_frac=roi_center_frac,
    degree_label="Cubic",
)
=== kodim14 | zoom=0.300 | shape=512×768 | ROI size≈256 px at center_frac=(0.75, 0.75) ===

Method                              Time (mean)       ± SD   SNR (dB)            MSE     SSIM
---------------------------------------------------------------------------------------------
SplineOps Standard cubic                 3.1 ms     0.0 ms      14.61      6.942e-03   0.6010
SplineOps Antialiasing cubic             5.8 ms     0.1 ms      16.58      4.417e-03   0.6797
OpenCV INTER_CUBIC                       0.4 ms     0.0 ms      14.64      6.909e-03   0.6114
SciPy cubic                             32.6 ms     0.1 ms      14.68      6.844e-03   0.6021
Pillow BICUBIC                           3.1 ms     0.1 ms      16.31      4.698e-03   0.6390
scikit-image cubic                      33.3 ms     0.1 ms      14.61      6.954e-03   0.6022
scikit-image cubic (AA)                 38.0 ms     0.0 ms      16.26      4.757e-03   0.6368
PyTorch bicubic (CPU)                    2.4 ms     0.0 ms      14.64      6.909e-03   0.6114
PyTorch bicubic (AA, CPU)                1.3 ms     0.0 ms      16.31      4.698e-03   0.6390

Original and Resized#

_color_intro_for_image(img_name, bench_kodim14)
Original image with ROI (512×768 px), Original ROI (256×256 px, NN magnified), SplineOps Antialiasing cubic (zoom ×0.3, 154×230 px), Antialiasing ROI (77×77 px, NN magnified)

ROI Comparison#

show_roi_montage_main_from_bench(bench_kodim14)
Original, SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU)

ROI Comparison Error#

show_error_montage_main_from_bench(bench_kodim14)
Normalized signed difference in ROI, Original (no diff), SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU), Diff legend

ROI Comparison (Antialiased)#

show_roi_montage_aa_from_bench(bench_kodim14)
Original, SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU)

ROI Comparison Error (Antialiased)#

show_error_montage_aa_from_bench(bench_kodim14)
Normalized signed difference in ROI, Original (no diff), SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU), Diff legend

ROI Color Comparison#

show_roi_montage_color_main_from_bench(bench_kodim14, orig_images_rgb[img_name])
Original (color), SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU)

ROI Color Comparison (Antialiased)#

show_roi_montage_color_aa_from_bench(bench_kodim14, orig_images_rgb[img_name])
Original (color), SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU)

Time Comparison#

show_timing_plot_from_bench(bench_kodim14)
Timing vs Method (H×W = 512×768, zoom ×0.3, degree=Cubic)

SNR/SSIM Comparison#

show_snr_ssim_plot_from_bench(bench_kodim14)
SNR / SSIM vs Method (ROI = 256×256 px, zoom ×0.3, degree=Cubic)

Image: kodim15#

img_name = "kodim15"
img_orig = orig_images[img_name]
cfg = IMAGE_CONFIG[img_name]
zoom = float(cfg["zoom"])
roi_size_px = int(cfg["roi_size_px"])
roi_center_frac = tuple(map(float, cfg["roi_center_frac"]))  # type: ignore[arg-type]

bench_kodim15 = benchmark_image(
    img_name=img_name,
    gray=img_orig,
    zoom=zoom,
    roi_size_px=roi_size_px,
    roi_center_frac=roi_center_frac,
    degree_label="Cubic",
)
=== kodim15 | zoom=0.300 | shape=512×768 | ROI size≈256 px at center_frac=(0.3, 0.55) ===

Method                              Time (mean)       ± SD   SNR (dB)            MSE     SSIM
---------------------------------------------------------------------------------------------
SplineOps Standard cubic                 3.1 ms     0.0 ms      17.97      2.034e-03   0.7223
SplineOps Antialiasing cubic             5.8 ms     0.0 ms      19.89      1.308e-03   0.7791
OpenCV INTER_CUBIC                       0.4 ms     0.0 ms      18.08      1.987e-03   0.7288
SciPy cubic                             32.5 ms     0.1 ms      17.97      2.034e-03   0.7223
Pillow BICUBIC                           3.1 ms     0.0 ms      19.36      1.479e-03   0.7627
scikit-image cubic                      33.5 ms     0.4 ms      17.99      2.025e-03   0.7231
scikit-image cubic (AA)                 37.8 ms     0.1 ms      19.31      1.494e-03   0.7624
PyTorch bicubic (CPU)                    2.4 ms     0.0 ms      18.08      1.987e-03   0.7288
PyTorch bicubic (AA, CPU)                1.5 ms     0.3 ms      19.36      1.479e-03   0.7627

Original and Resized#

_color_intro_for_image(img_name, bench_kodim15)
Original image with ROI (512×768 px), Original ROI (256×256 px, NN magnified), SplineOps Antialiasing cubic (zoom ×0.3, 154×230 px), Antialiasing ROI (77×77 px, NN magnified)

ROI Comparison#

show_roi_montage_main_from_bench(bench_kodim15)
Original, SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU)

ROI Comparison Error#

show_error_montage_main_from_bench(bench_kodim15)
Normalized signed difference in ROI, Original (no diff), SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU), Diff legend

ROI Comparison (Antialiased)#

show_roi_montage_aa_from_bench(bench_kodim15)
Original, SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU)

ROI Comparison Error (Antialiased)#

show_error_montage_aa_from_bench(bench_kodim15)
Normalized signed difference in ROI, Original (no diff), SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU), Diff legend

ROI Color Comparison#

show_roi_montage_color_main_from_bench(bench_kodim15, orig_images_rgb[img_name])
Original (color), SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU)

ROI Color Comparison (Antialiased)#

show_roi_montage_color_aa_from_bench(bench_kodim15, orig_images_rgb[img_name])
Original (color), SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU)

Time Comparison#

show_timing_plot_from_bench(bench_kodim15)
Timing vs Method (H×W = 512×768, zoom ×0.3, degree=Cubic)

SNR/SSIM Comparison#

show_snr_ssim_plot_from_bench(bench_kodim15)
SNR / SSIM vs Method (ROI = 256×256 px, zoom ×0.3, degree=Cubic)

Image: kodim19#

img_name = "kodim19"
img_orig = orig_images[img_name]
cfg = IMAGE_CONFIG[img_name]
zoom = float(cfg["zoom"])
roi_size_px = int(cfg["roi_size_px"])
roi_center_frac = tuple(map(float, cfg["roi_center_frac"]))  # type: ignore[arg-type]

bench_kodim19 = benchmark_image(
    img_name=img_name,
    gray=img_orig,
    zoom=zoom,
    roi_size_px=roi_size_px,
    roi_center_frac=roi_center_frac,
    degree_label="Cubic",
)
=== kodim19 | zoom=0.200 | shape=768×512 | ROI size≈256 px at center_frac=(0.65, 0.35) ===

Method                              Time (mean)       ± SD   SNR (dB)            MSE     SSIM
---------------------------------------------------------------------------------------------
SplineOps Standard cubic                 3.3 ms     0.0 ms      11.44      2.413e-02   0.4145
SplineOps Antialiasing cubic             5.2 ms     0.0 ms      13.82      1.394e-02   0.4540
OpenCV INTER_CUBIC                       0.4 ms     0.0 ms      11.52      2.367e-02   0.4287
SciPy cubic                             32.9 ms     0.0 ms      11.44      2.413e-02   0.4145
Pillow BICUBIC                           2.8 ms     0.0 ms      13.69      1.438e-02   0.4299
scikit-image cubic                      33.7 ms     0.1 ms      11.47      2.396e-02   0.4216
scikit-image cubic (AA)                 40.8 ms     0.1 ms      13.55      1.483e-02   0.4189
PyTorch bicubic (CPU)                    2.4 ms     0.0 ms      11.52      2.367e-02   0.4287
PyTorch bicubic (AA, CPU)                1.3 ms     0.0 ms      13.69      1.438e-02   0.4299

Original and Resized#

_color_intro_for_image(img_name, bench_kodim19)
Original image with ROI (768×512 px), Original ROI (256×256 px, NN magnified), SplineOps Antialiasing cubic (zoom ×0.2, 154×102 px), Antialiasing ROI (51×51 px, NN magnified)

ROI Comparison#

show_roi_montage_main_from_bench(bench_kodim19)
Original, SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU)

ROI Comparison Error#

show_error_montage_main_from_bench(bench_kodim19)
Normalized signed difference in ROI, Original (no diff), SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU), Diff legend

ROI Comparison (Antialiased)#

show_roi_montage_aa_from_bench(bench_kodim19)
Original, SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU)

ROI Comparison Error (Antialiased)#

show_error_montage_aa_from_bench(bench_kodim19)
Normalized signed difference in ROI, Original (no diff), SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU), Diff legend

ROI Color Comparison#

show_roi_montage_color_main_from_bench(bench_kodim19, orig_images_rgb[img_name])
Original (color), SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU)

ROI Color Comparison (Antialiased)#

show_roi_montage_color_aa_from_bench(bench_kodim19, orig_images_rgb[img_name])
Original (color), SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU)

Time Comparison#

show_timing_plot_from_bench(bench_kodim19)
Timing vs Method (H×W = 768×512, zoom ×0.2, degree=Cubic)

SNR/SSIM Comparison#

show_snr_ssim_plot_from_bench(bench_kodim19)
SNR / SSIM vs Method (ROI = 256×256 px, zoom ×0.2, degree=Cubic)

Image: kodim22#

img_name = "kodim22"
img_orig = orig_images[img_name]
cfg = IMAGE_CONFIG[img_name]
zoom = float(cfg["zoom"])
roi_size_px = int(cfg["roi_size_px"])
roi_center_frac = tuple(map(float, cfg["roi_center_frac"]))  # type: ignore[arg-type]

bench_kodim22 = benchmark_image(
    img_name=img_name,
    gray=img_orig,
    zoom=zoom,
    roi_size_px=roi_size_px,
    roi_center_frac=roi_center_frac,
    degree_label="Cubic",
)
=== kodim22 | zoom=0.200 | shape=512×768 | ROI size≈256 px at center_frac=(0.5, 0.25) ===

Method                              Time (mean)       ± SD   SNR (dB)            MSE     SSIM
---------------------------------------------------------------------------------------------
SplineOps Standard cubic                 2.7 ms     0.0 ms      12.26      8.970e-03   0.4499
SplineOps Antialiasing cubic             5.1 ms     0.0 ms      14.70      5.118e-03   0.5099
OpenCV INTER_CUBIC                       0.4 ms     0.0 ms      12.46      8.567e-03   0.4574
SciPy cubic                             31.4 ms     0.1 ms      12.26      8.970e-03   0.4499
Pillow BICUBIC                           2.8 ms     0.0 ms      14.49      5.370e-03   0.4823
scikit-image cubic                      32.0 ms     0.1 ms      12.40      8.688e-03   0.4482
scikit-image cubic (AA)                 38.1 ms     0.1 ms      14.40      5.482e-03   0.4728
PyTorch bicubic (CPU)                    2.5 ms     0.0 ms      12.46      8.567e-03   0.4574
PyTorch bicubic (AA, CPU)                1.3 ms     0.0 ms      14.49      5.370e-03   0.4823

Original and Resized#

_color_intro_for_image(img_name, bench_kodim22)
Original image with ROI (512×768 px), Original ROI (256×256 px, NN magnified), SplineOps Antialiasing cubic (zoom ×0.2, 102×154 px), Antialiasing ROI (51×51 px, NN magnified)

ROI Comparison#

show_roi_montage_main_from_bench(bench_kodim22)
Original, SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU)

ROI Comparison Error#

show_error_montage_main_from_bench(bench_kodim22)
Normalized signed difference in ROI, Original (no diff), SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU), Diff legend

ROI Comparison (Antialiased)#

show_roi_montage_aa_from_bench(bench_kodim22)
Original, SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU)

ROI Comparison Error (Antialiased)#

show_error_montage_aa_from_bench(bench_kodim22)
Normalized signed difference in ROI, Original (no diff), SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU), Diff legend

ROI Color Comparison#

show_roi_montage_color_main_from_bench(bench_kodim22, orig_images_rgb[img_name])
Original (color), SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU)

ROI Color Comparison (Antialiased)#

show_roi_montage_color_aa_from_bench(bench_kodim22, orig_images_rgb[img_name])
Original (color), SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU)

Time Comparison#

show_timing_plot_from_bench(bench_kodim22)
Timing vs Method (H×W = 512×768, zoom ×0.2, degree=Cubic)

SNR/SSIM Comparison#

show_snr_ssim_plot_from_bench(bench_kodim22)
SNR / SSIM vs Method (ROI = 256×256 px, zoom ×0.2, degree=Cubic)

Image: kodim23#

img_name = "kodim23"
img_orig = orig_images[img_name]
cfg = IMAGE_CONFIG[img_name]
zoom = float(cfg["zoom"])
roi_size_px = int(cfg["roi_size_px"])
roi_center_frac = tuple(map(float, cfg["roi_center_frac"]))  # type: ignore[arg-type]

bench_kodim23 = benchmark_image(
    img_name=img_name,
    gray=img_orig,
    zoom=zoom,
    roi_size_px=roi_size_px,
    roi_center_frac=roi_center_frac,
    degree_label="Cubic",
)
=== kodim23 | zoom=0.150 | shape=512×768 | ROI size≈256 px at center_frac=(0.4, 0.65) ===

Method                              Time (mean)       ± SD   SNR (dB)            MSE     SSIM
---------------------------------------------------------------------------------------------
SplineOps Standard cubic                 2.5 ms     0.0 ms      16.45      4.817e-03   0.6700
SplineOps Antialiasing cubic             4.7 ms     0.1 ms      18.58      2.947e-03   0.7029
OpenCV INTER_CUBIC                       0.4 ms     0.0 ms      16.39      4.885e-03   0.6758
SciPy cubic                             31.5 ms     0.7 ms      16.45      4.817e-03   0.6700
Pillow BICUBIC                           2.8 ms     0.0 ms      18.29      3.155e-03   0.6917
scikit-image cubic                      31.6 ms     0.1 ms      16.39      4.878e-03   0.6714
scikit-image cubic (AA)                 39.3 ms     0.1 ms      18.07      3.316e-03   0.6852
PyTorch bicubic (CPU)                    2.3 ms     0.0 ms      16.39      4.885e-03   0.6758
PyTorch bicubic (AA, CPU)                1.3 ms     0.0 ms      18.29      3.155e-03   0.6917

Original and Resized#

_color_intro_for_image(img_name, bench_kodim23)
Original image with ROI (512×768 px), Original ROI (256×256 px, NN magnified), SplineOps Antialiasing cubic (zoom ×0.15, 77×115 px), Antialiasing ROI (38×38 px, NN magnified)

ROI Comparison#

show_roi_montage_main_from_bench(bench_kodim23)
Original, SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU)

ROI Comparison Error#

show_error_montage_main_from_bench(bench_kodim23)
Normalized signed difference in ROI, Original (no diff), SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU), Diff legend

ROI Comparison (Antialiased)#

show_roi_montage_aa_from_bench(bench_kodim23)
Original, SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU)

ROI Comparison Error (Antialiased)#

show_error_montage_aa_from_bench(bench_kodim23)
Normalized signed difference in ROI, Original (no diff), SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU), Diff legend

ROI Color Comparison#

show_roi_montage_color_main_from_bench(bench_kodim23, orig_images_rgb[img_name])
Original (color), SplineOps Standard cubic, SplineOps Antialiasing cubic, OpenCV INTER_CUBIC, SciPy cubic, scikit-image cubic, PyTorch bicubic (CPU)

ROI Color Comparison (Antialiased)#

show_roi_montage_color_aa_from_bench(bench_kodim23, orig_images_rgb[img_name])
Original (color), SplineOps Standard cubic, SplineOps Antialiasing cubic, Pillow BICUBIC, scikit-image cubic (AA), PyTorch bicubic (AA, CPU)

Time Comparison#

show_timing_plot_from_bench(bench_kodim23)
Timing vs Method (H×W = 512×768, zoom ×0.15, degree=Cubic)

SNR/SSIM Comparison#

show_snr_ssim_plot_from_bench(bench_kodim23)
SNR / SSIM vs Method (ROI = 256×256 px, zoom ×0.15, degree=Cubic)

Runtime Context#

Finally, we print a short summary of the runtime environment and the storage dtype used for the benchmark.

if _HAS_SPECS and print_runtime_context is not None:
    print_runtime_context(include_threadpools=True)
print(f"Benchmark storage dtype: {np.dtype(DTYPE).name}")
Runtime context:
  Python      : 3.12.12 (CPython)
  OS          : Linux 6.11.0-1018-azure (x86_64)
  CPU         : x86_64 | logical cores: 4
  Process     : pid=2422 | Python threads=1
  NumPy/SciPy : 2.2.6/1.16.3
  Matplotlib  : 3.10.8 | backend: agg
  splineops   : 1.2.1 | native ext present: True
  Extra libs  : Pillow=12.0.0, OpenCV=4.12.0, scikit-image=0.25.2, PyTorch=2.9.1+cu128
  SPLINEOPS_ACCEL=always
  OMP_NUM_THREADS=4
Benchmark storage dtype: float32

Total running time of the script: (0 minutes 53.155 seconds)

Gallery generated by Sphinx-Gallery