S-Spline 算法 Python+NumPy 实现

"""NumPy reimplementation of the image-resampling core identified in S-Spline.exe.

原有二进制的图像处理核心基于"可分离"(separable)的图像操作和"带裁剪的位图拷贝"。
本模块用 NumPy 将这些操作移植为可移植、向量化的版本,力求语义与原二进制一致。

整体设计:
- S-Spline 使用可分离的三次 B 样条(cubic B-spline)采样:先在水平方向重采样,
  再在垂直方向重采样,两次一维滤波等价于二维卷积,但计算量更小。
- 为每个目标像素预计算其 4 个采样源坐标(4-tap)与对应权重,形成稀疏采样矩阵,
  随后通过 np.take / 广播乘法一次性完成整幅图像的采样。
- 图像边界处使用"镜像映射"(mirror)避免下标越界,同时保留一定的对称性。

模块包含三个核心能力:
1. resize_rgb(...)    —— 基于可分离三次 B 样条的无损/高阶图像缩放
2. clipped_copy(...)  —— 带边界裁剪的行拷贝(对应 S-Spline.exe 0x459194 处逻辑)
3. resize_file(...)   —— 基于 Pillow 的文件级缩放封装,便于命令行调用
"""

from __future__ import annotations

import argparse
import math  # 保留导入;后续实现可能用到数值工具(本文件未直接使用)
from pathlib import Path
from io import BytesIO

import numpy as np
from PIL import Image


def _mirror_indices(indices: np.ndarray, length: int) -> np.ndarray:
    """在图像边界处对整数坐标做"镜像"反射,将其映射回 [0, length) 区间。

    镜像映射(clamp 与 wrap 之外的另一类边界处理):
    - 与直接截断(clamp)相比,镜像避免了边界处权重的突变;
    - 与循环(wrap)相比,镜像不会让图像左右/上下"缝合"在一起。
    周期为 p = 2*length - 2,形如: 0,1,2,...,length-1,length-2,...,1,0,...

    参数:
        indices: 可能越界的整数坐标数组。
        length:  一维方向上的合法下标上界(即该方向上的像素数量)。

    返回:
        与 indices 同形状的合法下标数组(np.intp)。
    """
    # 长度为 1 时该方向退化为单个像素,任何坐标都应映射到 0。
    if length <= 1:
        return np.zeros_like(indices, dtype=np.intp)
    period = 2 * length - 2  # 镜像的周期。
    indices = np.mod(indices, period)  # 先折入一个周期内,去除整数倍周期偏移。
    # 周期内又分两段:前半段 [0, length) 保持原值,后半段(大于等于 length)做对称折叠。
    return np.where(indices < length, indices, period - indices).astype(np.intp)


def _cubic_bspline(distance: np.ndarray) -> np.ndarray:
    """逐元素计算"居中三次 B 样条"核函数在给定距离处的取值。

    三次 B 样条是一类光滑的插值/滤波核,用于加权邻近像素。
    影响半径取 2 个像素,因此每点需要 4 个源像素参与采样(4-tap)。
    它以分段多项式的形式定义,|x| < 2 时非零,越往中心权重越大且核本身连续光滑。

    归一化的三次 B 样条定义(经典 Mitchel-Netravali 族 / uniform cubic spline):
        当 0 <= |x| < 1:  w = (4 - 6|x|^2 + 3|x|^3) / 6
        当 1 <= |x| < 2:  w = (2 - |x|)^3 / 6
        其余:              w = 0

    参数:
        distance: 各像素到采样中心的有符号(带符号)距离数组,元素可为任意实数。

    返回:
        与 distance 同形状的权重数组(np.float64),非负且和为 1(当距离恰落在
        半径内时;若所有距离都落在核对径之外则返回 0)。
    """
    distance = np.abs(distance)  # 核函数关于原点偶对称,只需看绝对值。
    weights = np.zeros_like(distance, dtype=np.float64)
    inner = distance < 1.0  # 中心区间:0 ~ 1。
    outer = (distance >= 1.0) & (distance < 2.0)  # 外围区间:1 ~ 2。
    weights[inner] = (4.0 - 6.0 * distance[inner] ** 2
                      + 3.0 * distance[inner] ** 3) / 6.0
    delta = 2.0 - distance[outer]
    weights[outer] = delta**3 / 6.0
    return weights


def _axis_weights(destination_size: int, source_size: int,
                  scale: float) -> tuple[np.ndarray, np.ndarray]:
    """为单轴构造一个"归一化的稀疏 4-tap 采样矩阵"。

    思路:对目标轴上每一个像素,找出它在源轴上参与采样的 4 个源坐标(完整、
    不裁剪),并按自定义的采样相位(pixel center 对齐)计算对应权重。

    采样相位说明:
        - 目标像素的"像素中心"位置按 (i + 0.5) 计算(i 为目标下标);
        - 再除以缩放比 scale 得到对应的源像素中心位置;
        - 减去 0.5 得到"源像素中心到源采样点"的连续坐标,便于与整数源下标对齐。
      这样当 scale == 1 时,目标下标 i 恰好对应源的下标 i,无漂移。

    4-tap 采样:取 floor(pos) - 1 起连续 4 个源坐标,覆盖三次 B 样条 ±2 的
    影响半径;超出边界处用 _mirror_indices 做镜像反射。

    参数:
        destination_size: 目标轴长度(最终输出的宽/高)。
        source_size:      源轴长度(输入图像的宽/高)。
        scale:            缩放系数 = destination / source(大于 1 放大,小于 1 缩小)。

    返回:
        (coordinates, weights) 两个形状为 (destination_size, 4) 的数组:
        - coordinates: 每个目标像素对应的 4 个源坐标(整数下标,[0, source_size));
        - weights:     与 coordinates 对应的权重,未归一化(行内和为 1 由调用方保证)。
    """
    # 目标像素中心在"源坐标系"下的连续位置。
    positions = (np.arange(destination_size, dtype=np.float64) + 0.5) / scale - 0.5
    # 4-tap 的起始源下标:取 floor(pos) 的前一个位置起(左对齐的 4 邻域)。
    left = np.floor(positions).astype(np.intp) - 1
    offsets = np.arange(4, dtype=np.intp)  # 相对偏移:0,1,2,3。
    # 每个目标像素 -> 4 个源坐标(边界处镜像),形状 (destination_size, 4)。
    coordinates = _mirror_indices(left[:, None] + offsets[None, :], source_size)
    # 各源坐标到采样中心的连续距离,代入 B 样条核得到初步权重。
    weights = _cubic_bspline(positions[:, None] - (left[:, None] + offsets[None, :]))
    # 归一化:每行权重之和归一为 1,避免因裁剪/相位造成亮度偏差。
    # 除零保护:用机器精度极小值代替 0 作分母,防止空行导致 NaN/Inf。
    weights /= np.maximum(weights.sum(axis=1, keepdims=True), np.finfo(np.float64).eps)
    return coordinates, weights

def scale_rgb(pixels: np.ndarray | bytes, multiple: float):
    if isinstance(pixels, bytes):
        w, h = Image.open(BytesIO(pixels)).size
    else :
        h, w, *_ = pixels.shape
    return resize_rgb(pixels, int(w * multiple), int(h * multiple))

def resize_rgb(pixels: np.ndarray | bytes, width: int, height: int) -> np.ndarray | bytes:
    """用可分离三次 B 样条采样对 RGB/RGBA 图像做缩放。

    流程分两步(可分离性):
        1. 水平方向:对每个目标列,沿水平轴用 `_axis_weights(width, src_w, sx)`
           采样,得到形状 (H, dest_W, C) 的中间结果;
        2. 垂直方向:对上一步结果,沿垂直轴用 `_axis_weights(height, src_h, sy)`
           再采样一次,得到最终 (dest_H, dest_W, C)。

    参数:
        pixels: 形状为 (source_height, source_width, channels) 的数组,
                通道数须为 3(RGB)或 4(RGBA)。
        width:  目标宽度(输出列数)。
        height: 目标高度(输出行数)。

    返回:
        uint8 类型的 NumPy 数组,形状为 (height, width, channels)。

    异常:
        ValueError: 输入不是合法的 (H, W, 3/4) 数组,或宽/高非正数。
    """
    is_bytes = isinstance(pixels, bytes)
    if is_bytes:
        pixels = np.asarray(Image.open(BytesIO(pixels)))
    source = np.asarray(pixels)
    if source.ndim != 3 or source.shape[2] not in (3, 4):
        raise ValueError("pixels must have shape (height, width, 3) or (height, width, 4)")
    if width < 1 or height < 1:
        raise ValueError("destination dimensions must be positive")

    source_height, source_width, _ = source.shape
    # 浮点化以便做加权求和(copy=False:仅在需要时拷贝,不改变调用方数据)。
    source = source.astype(np.float64, copy=False)
    # 分别构造水平、垂直两轴的采样矩阵(坐标 + 权重)。
    x_indices, x_weights = _axis_weights(width, source_width, width / source_width)
    y_indices, y_weights = _axis_weights(height, source_height, height / source_height)

    # ---- 水平通道: (H, W, C) -> (H, dest_W, C) ----
    # np.take 沿 axis=1 收集每个目标列对应的 4 个源列 -> 形状 (H, dest_W, 4, C)。
    horizontal = np.take(source, x_indices, axis=1)
    # 按 [None, :, :, None] 广播权重,沿 axis=2(4-tap 维度)加权求和 -> (H, dest_W, C)。
    horizontal = np.sum(horizontal * x_weights[None, :, :, None], axis=2)

    # ---- 垂直通道: (H, dest_W, C) -> (dest_H, dest_W, C) ----
    # np.take 沿 axis=0 收集每行的 4 个源行 -> 形状 (dest_H, dest_W, 4, C)。
    result = np.take(horizontal, y_indices, axis=0)
    # 按 [:, :, None, None] 广播权重,沿 axis=1(4-tap 维度)加权求和。
    result = np.sum(result * y_weights[:, :, None, None], axis=1)

    # 四舍五入并对抗裁剪到 [0, 255],转回 uint8 输出。
    result = np.clip(np.rint(result), 0, 255).astype(np.uint8)
    
    if is_bytes:
        img = Image.fromarray(result)
        bio = BytesIO()
        img.save(bio, 'PNG')
        result = bio.getvalue()
    return result


def clipped_copy(src: np.ndarray, dst: np.ndarray, src_x: int, src_y: int,
                 dst_x: int, dst_y: int, width: int, height: int) -> None:
    """NumPy 版"带裁剪的行拷贝",对应 S-Spline.exe 0x459194 处观察到的逻辑。

    该操作将 src 以 (src_x, src_y) 为左上角、宽 width、高 height 的矩形区域,
    原样拷贝到 dst 以 (dst_x, dst_y) 为左上角的对应区域。两个数组的边界都会被
    用于裁剪:任何越出 src 或 dst 的部分都会自动收窄,而不是抛出越界错误。
    支持 RGB/RGBA 或任意"尾部通道形状相同"的数组,原地修改 dst。

    参数:
        src:  源数组,至少二维,形状 (src_h, src_w[, C])。
        dst:  目标数组,原地更新,形状须与 src 通道维度一致。
        src_x, src_y: 源矩形左上角坐标。
        dst_x, dst_y: 目标矩形左上角坐标。
        width, height: 待拷贝区域的宽、高(可为负或 0,此时直接返回不做任何事)。

    异常:
        ValueError: src/dst 维度不足,或通道形状不匹配。
    """
    source = np.asarray(src)
    target = np.asarray(dst)
    if source.ndim < 2 or target.ndim < 2:
        raise ValueError("src and dst must be at least two-dimensional")
    if width <= 0 or height <= 0:
        return  # 尺寸无效,无需拷贝。
    if source.ndim != target.ndim or source.shape[2:] != target.shape[2:]:
        raise ValueError("src and dst channel shapes must match")

    # 用可变副本进行裁剪计算(保持原参数不变)。
    sx, sy, dx, dy = src_x, src_y, dst_x, dst_y

    # 1) 左上越界裁剪:若源或目标左上角为负,则同步平移起点并收窄尺寸,
    #    保证重叠区域从边界开始。例如源左上越到 0 左侧时,把目标起点右移,
    #    同时缩小拷贝宽度。
    if sx < 0:
        dx -= sx
        width += sx
        sx = 0
    if sy < 0:
        dy -= sy
        height += sy
        sy = 0
    if dx < 0:
        sx -= dx
        width += dx
        dx = 0
    if dy < 0:
        sy -= dy
        height += dy
        dy = 0

    # 2) 右下越界裁剪:取"请求宽度"与"src 剩余宽度 / dst 剩余宽度"的最小值,
    #    保证不会越过任一数组右/下边界。
    width = min(width, source.shape[1] - sx, target.shape[1] - dx)
    height = min(height, source.shape[0] - sy, target.shape[0] - dy)
    if width <= 0 or height <= 0:
        return  # 裁剪后重叠区域为空,直接返回。

    # 3) 执行真正的切片拷贝(行优先的高效 C 级拷贝)。
    #    target[dy:dy+height, dx:dx+width] = source[sy:sy+height, sx:sx+width]
    target[dy:dy + height, dx:dx + width] = source[sy:sy + height, sx:sx + width]


posted @ 2026-09-13 02:30  绝不原创的飞龙  阅读(39)  评论(0)    收藏  举报