SDF 介绍

Signed Distance Field (SDF)

Signed Distance Field(有符号距离场)1是一种用于表示形状的数学函数或数据结构。
它在二维或三维空间中为每个点分配一个带符号的距离值,表示该点到最近表面(或边界)的距离,并用符号区分内外:

  • 正值:点位于形状外部,数值表示距离表面的最近距离。
  • 负值:点位于形状内部,绝对值表示距离表面的最近距离。
  • :点正好位于形状的表面上。

形式化定义,对于空间中的点 $x$,SDF 可以表示为:

$$ f(x) = \begin{cases} d(x, \partial\Omega), & \text{if } x \in \Omega \\ -d(x, \partial\Omega), & \text{if } x \notin \Omega \\ 0, & \text{if } x \in \partial\Omega \end{cases} $$

其中的$\Omega$属于物体内,$\partial\Omega$表示边界,$d(x, \partial\Omega)$表示点$x$到边界的最小距离。

GitHub 仓库

如果你想查看最终的生成SDF图片的代码,可以在GitHub仓库2获取:cronrpc/Signed-Distance-Field-2D-Generator

应用

上面的说法可能非常抽象,我们看一些具体的应用实例。

  • 字体渲染(Valve 的 SDF 字体技术3),Unity的TMP字体就是使用了SDF技术。

valve-sdf-text-example

  • 光线追踪中的表面求交

  • 风格化阴影、云等

凡是某种连续过渡的参数,都可以考虑使用SDF技术。

2D SDF 生成算法

对于一张灰度图,白色部分表示物体,黑色是背景。如何生成它的SDF图呢?

circle

暴力求值法

一个最为简单的方式是,遍历算法。

  1. 首先我们找出所有的边界集合。也就是当前像素是白色,而周围像素有黑色的点。
  2. 遍历所有的点,计算距离边界集合的最近距离。
  3. 根据本身的黑白,添加距离的正负号。

如果假设像素点的数目是$n$,那么复杂度是$O(n^2)$。

# generator_sdf.py
import sys
import math
from PIL import Image
import numpy as np

def generate_sdf(input_path, output_path):
    # 读取灰度图(0-255)
    img = Image.open(input_path).convert("L")
    w, h = img.size
    pixels = np.array(img)

    # Step 1: 找出边界集合
    boundary_points = []
    for y in range(h):
        for x in range(w):
            if pixels[y, x] > 127:  # 白色
                # 检查周围像素是否有黑色
                neighbors = [
                    (nx, ny)
                    for nx in (x - 1, x, x + 1)
                    for ny in (y - 1, y, y + 1)
                    if 0 <= nx < w and 0 <= ny < h and not (nx == x and ny == y)
                ]
                for nx, ny in neighbors:
                    if pixels[ny, nx] <= 127:  # 黑色
                        boundary_points.append((x, y))
                        break

    # Step 2 & 3: 计算SDF
    sdf = np.zeros((h, w), dtype=np.float32)
    for y in range(h):
        for x in range(w):
            min_dist = float("inf")
            for bx, by in boundary_points:
                dist = math.sqrt((x - bx) ** 2 + (y - by) ** 2)
                if dist < min_dist:
                    min_dist = dist
            # 黑色取负值
            if pixels[y, x] <= 127:
                min_dist = -min_dist
            sdf[y, x] = min_dist

    # 归一化到0-255
    max_dist = np.max(np.abs(sdf))
    sdf_normalized = ((sdf / max_dist) + 1) * 127.5
    sdf_img = Image.fromarray(np.clip(sdf_normalized, 0, 255).astype(np.uint8))
    sdf_img.save(output_path)
    print(f"SDF saved to {output_path}")

if __name__ == "__main__":
    if len(sys.argv) != 3:
        print("Example: python generator_sdf.py test.png test_sdf.png")
        sys.exit(1)
    generate_sdf(sys.argv[1], sys.argv[2])

生成的结果:

Cicrl SDF

通过在PS中改变图片的显示阈值,可以观察到圆形的SDF变化:

Circle SDF Threshold

同理在正方形的SDF中,也可以观察效果。正方形的SDF的特点就是在内部时等高线是直角,在外部时等高线是圆弧。

Square SDF Threshold

八邻域欧几里得近似距离

度量空间4(Metric Space)是数学中刻画“距离”这一概念的抽象框架。它由一个集合 $X$ 及其上的距离函数

$$ d : X \times X \to \mathbb{R} $$

构成,并且这个距离函数必须满足以下四个条件:

  1. 非负性:距离永远是非负数。

  2. 恒等性:距离为零时,两点必须相同。

  3. 对称性:从 $x$ 到 $y$ 的距离等于从 $y$ 到 $x$ 的距离。

  4. 三角不等式:直接到达不应比绕路更远。

    $$ d(x, z) \le d(x, y) + d(y, z) $$

例如,在连续的二维平面中,欧几里得距离:

$$ d(P, Q) = \sqrt{(x_P - x_Q)^2 + (y_P - y_Q)^2} $$

是一个典型的度量函数。

下图的蓝色线条、红色线条、黄色线条都是表示同样的长度,而绿色是比它们都短的欧几里得距离。

Metric Space

然而在数字图像处理栅格地图中,我们的点是离散的像素格,距离计算可以直接用欧几里得公式,但这样需要大量平方根运算,代价较高。 为提高效率,常采用 八邻域欧几里得近似距离(8-neighborhood Euclidean approximation):

  • 八邻域:指每个像素与其水平方向、垂直方向和对角线方向上最近的 8 个像素相邻。
  • 在这种邻接关系下,可以用下式近似欧几里得距离:

$$ d_{8}(p, q) \approx \max(\Delta x, \Delta y) + (\sqrt{2} - 1) \cdot \min(\Delta x, \Delta y) $$

其中 $\Delta x = |x_p - x_q|$,$\Delta y = |y_p - y_q|$。

例如下图中的红色就是表示八邻域欧几里得近似距离,而蓝色直线就是欧几里得距离。

8-neighborhood Euclidean approximation):

这种方法避免了大量开方计算,同时在 8 邻域范围内与真实欧几里得距离的误差很小,因此常用于路径搜索(A*、Dijkstra)、距离变换图像形态学等场景。

8SSEDT

对这个算法的介绍参考自Lisapple 8SSEDT5

在上一小节中,我们已经介绍了八邻域欧几里得近似距离。实际上,对于图中的每个节点,其距离值可以用如下递推关系表示:

$$ f(x, y) = \min_{\substack{dx, dy \in {-1,0,1} \ (dx, dy) \neq (0,0)}} \Big( f(x+dx, y+dy) + d(dx, dy) \Big) $$

其中,$d(dx, dy)$ 表示从邻居节点 $(x+dx, y+dy)$ 移动到节点 $(x, y)$ 的代价,通常取:

$$ d(dx, dy) = \begin{cases} 1, & \text{若 } |dx| + |dy| = 1 \quad (\text{水平或垂直邻居})\\ \sqrt{2}, & \text{若 } |dx| + |dy| = 2 \quad (\text{对角邻居}) \end{cases} $$

这里,$dx$ 和 $dy$ 可以分别取 $-1, 0, 1$,但不能同时为零,这样才是邻居节点。

如果给邻居节点进行标号:

[#1][#2][#3]
[#4][ x][#5]
[#6][#7][#8]

对于任意两个点$x$和$y$之间的距离路径,只可能由下面4个组合中的1种构成,也就是说必然是其中之一的线性叠加:

1,2,4

2,3,5

4,6,7

5,7,8

所以从左上角递推到右下角,就可以覆盖第1种$1,2,4$的可能,也就是说最多$4$次遍历整个图,就能确定所有点的值。

当然,这里进行了一点优化,参考Signed Distance Fields6的实现,他选择了下列$4$个遍历路径:

   - - - >
| [?][?][?]
| [?][x][ ]
v [ ][ ][ ]

   < - - -
| [ ][ ][ ]
| [ ][x][?]
v [ ][ ][ ]

   < - - -
^ [ ][ ][ ]
| [ ][x][?]
| [?][?][?]

   - - - >
^ [ ][ ][ ]
| [?][x][ ]
| [ ][ ][ ]

这里直接给出Python的实现:

  • 准确来说下列代码的距离定义是欧几里得距离,并不是八邻域欧几里得近似距离。
  • 只是借鉴了移动方向为8个方向
import sys
import os
import numpy as np
from PIL import Image

INF = 9999

def dist_sq(dx, dy):
    return dx*dx + dy*dy

def compare(grid, p, x, y, ox, oy, width, height):
    nx = x + ox
    ny = y + oy
    if 0 <= nx < width and 0 <= ny < height:
        other_dx, other_dy = grid[ny, nx]
    else:
        other_dx, other_dy = INF, INF
    other_dx += ox
    other_dy += oy
    if dist_sq(other_dx, other_dy) < dist_sq(*p):
        p = (other_dx, other_dy)
    return p

def generate_sdf(grid):
    height, width, _ = grid.shape
    # Pass 0
    for y in range(height):
        for x in range(width):
            p = tuple(grid[y, x])
            p = compare(grid, p, x, y, -1,  0, width, height)
            p = compare(grid, p, x, y,  0, -1, width, height)
            p = compare(grid, p, x, y, -1, -1, width, height)
            p = compare(grid, p, x, y,  1, -1, width, height)
            grid[y, x] = p
        for x in range(width-1, -1, -1):
            p = tuple(grid[y, x])
            p = compare(grid, p, x, y, 1, 0, width, height)
            grid[y, x] = p

    # Pass 1
    for y in range(height-1, -1, -1):
        for x in range(width-1, -1, -1):
            p = tuple(grid[y, x])
            p = compare(grid, p, x, y,  1,  0, width, height)
            p = compare(grid, p, x, y,  0,  1, width, height)
            p = compare(grid, p, x, y, -1,  1, width, height)
            p = compare(grid, p, x, y,  1,  1, width, height)
            grid[y, x] = p
        for x in range(width):
            p = tuple(grid[y, x])
            p = compare(grid, p, x, y, -1, 0, width, height)
            grid[y, x] = p

def load_image_binary(path, threshold=128):
    im = Image.open(path).convert('L')
    arr = np.array(im, dtype=np.uint8)
    inside = arr < threshold
    return inside, im

def save_signed_sdf_image(signed, out_path):
    # 归一化到0-255,128为边界
    max_dist = np.max(np.abs(signed))
    if max_dist == 0:  # 防止除以0
        sdf_normalized = np.full_like(signed, 128.0)
    else:
        sdf_normalized = ((signed / max_dist) + 1.0) * 128.0
    sdf_normalized = np.clip(sdf_normalized, 0, 255).astype(np.uint8)
    out = Image.fromarray(sdf_normalized, mode='L')
    out.save(out_path)

def main():
    if len(sys.argv) < 2:
        print("Usage: python 8ssedt.py input.png")
        sys.exit(1)

    in_path = sys.argv[1]
    if not os.path.exists(in_path):
        print("File not found:", in_path)
        sys.exit(1)

    inside, im = load_image_binary(in_path)
    h, w = inside.shape
    empty = (INF, INF)
    zero = (0, 0)

    # two grids: inside distances and outside distances
    grid1 = np.zeros((h, w, 2), dtype=int)
    grid2 = np.zeros((h, w, 2), dtype=int)

    for y in range(h):
        for x in range(w):
            if inside[y, x]:
                grid1[y, x] = zero
                grid2[y, x] = empty
            else:
                grid1[y, x] = empty
                grid2[y, x] = zero

    generate_sdf(grid1)
    generate_sdf(grid2)

    dist1 = np.sqrt(grid1[:, :, 0]**2 + grid1[:, :, 1]**2)
    dist2 = np.sqrt(grid2[:, :, 0]**2 + grid2[:, :, 1]**2)
    signed = dist1 - dist2

    base, ext = os.path.splitext(in_path)
    out_path = base + "_8ssedt.png"
    save_signed_sdf_image(signed, out_path)
    print("Saved:", out_path)

if __name__ == "__main__":
    main()

我们用它实验的这张原图来看看效果:

image test

在运行我们的脚本后,得到:

image test 8ssedt sdf

放入PS中,查看不同阈值下的效果:

image-test-8ssedt-threshold

Correct 8SSEDT

参考7,他解决的问题是,对于2值的黑白图来说,邻近边界的点,距离边界的距离实质上应该只有半个像素。

考虑到这半个像素的差别,会导致在接近边界的地方有一些误差,解决方法就是旁边的点是边界的时候,距离就只加一半的大小。

import sys
import os
import math
import numpy as np
from PIL import Image

FIX = True

INF = 9999

def dist_sq(dx, dy):
    return dx*dx + dy*dy

def compare(grid, p, x, y, ox, oy, width, height):
    nx = x + ox
    ny = y + oy
    if 0 <= nx < width and 0 <= ny < height:
        other_dx, other_dy = grid[ny, nx]
    else:
        other_dx, other_dy = INF, INF

    if FIX:
        if other_dx != 0 or other_dy != 0:  # 对应 other.DistSq()!=0
            ox *= 2
            oy *= 2

    other_dx += ox
    other_dy += oy
    if dist_sq(other_dx, other_dy) < dist_sq(*p):
        p = (other_dx, other_dy)
    return p

def generate_sdf(grid):
    height, width, _ = grid.shape
    # Pass 0
    for y in range(height):
        for x in range(width):
            p = tuple(grid[y, x])
            p = compare(grid, p, x, y, -1,  0, width, height)
            p = compare(grid, p, x, y,  0, -1, width, height)
            p = compare(grid, p, x, y, -1, -1, width, height)
            p = compare(grid, p, x, y,  1, -1, width, height)
            grid[y, x] = p
        for x in range(width-1, -1, -1):
            p = tuple(grid[y, x])
            p = compare(grid, p, x, y, 1, 0, width, height)
            grid[y, x] = p

    # Pass 1
    for y in range(height-1, -1, -1):
        for x in range(width-1, -1, -1):
            p = tuple(grid[y, x])
            p = compare(grid, p, x, y,  1,  0, width, height)
            p = compare(grid, p, x, y,  0,  1, width, height)
            p = compare(grid, p, x, y, -1,  1, width, height)
            p = compare(grid, p, x, y,  1,  1, width, height)
            grid[y, x] = p
        for x in range(width):
            p = tuple(grid[y, x])
            p = compare(grid, p, x, y, -1, 0, width, height)
            grid[y, x] = p

def load_image_binary(path, threshold=128):
    im = Image.open(path).convert('L')
    arr = np.array(im, dtype=np.uint8)
    inside = arr < threshold
    return inside, im

def save_signed_sdf_image(signed, out_path):
    # 归一化到0-255,128为边界
    max_dist = np.max(np.abs(signed))
    if max_dist == 0:  # 防止除以0
        sdf_normalized = np.full_like(signed, 128.0)
    else:
        sdf_normalized = ((signed / max_dist) + 1.0) * 128.0
    sdf_normalized = np.clip(sdf_normalized, 0, 255).astype(np.uint8)
    out = Image.fromarray(sdf_normalized, mode='L')
    out.save(out_path)

def main():
    if len(sys.argv) < 2:
        print("Usage: python 8ssedt.py input.png")
        sys.exit(1)

    in_path = sys.argv[1]
    if not os.path.exists(in_path):
        print("File not found:", in_path)
        sys.exit(1)

    inside, im = load_image_binary(in_path)
    h, w = inside.shape
    empty = (INF, INF)
    zero = (0, 0)

    # two grids: inside distances and outside distances
    grid1 = np.zeros((h, w, 2), dtype=int)
    grid2 = np.zeros((h, w, 2), dtype=int)

    for y in range(h):
        for x in range(w):
            if inside[y, x]:
                grid1[y, x] = zero
                grid2[y, x] = empty
            else:
                grid1[y, x] = empty
                grid2[y, x] = zero

    generate_sdf(grid1)
    generate_sdf(grid2)

    dist1 = np.sqrt(grid1[:, :, 0]**2 + grid1[:, :, 1]**2)
    dist2 = np.sqrt(grid2[:, :, 0]**2 + grid2[:, :, 1]**2)

    if FIX:
        signed = 0.5 * (dist1 - dist2)
    else:
        signed = dist1 - dist2

    base, ext = os.path.splitext(in_path)
    out_path = base + "_8ssedt_correct.png"
    save_signed_sdf_image(signed, out_path)
    print("Saved:", out_path)

if __name__ == "__main__":
    main()

从效果上看,几乎是和之前一样:

image_test_8ssedt_correct

但是从阈值角度来看,会稍微平滑一些,观察接近地方的值就知道了。

修正后的结果:

image_test_8ssedt_correct_threshold

原来的结果:

image_test_8ssedt_threshold

为什么还是用欧几里得距离?

用公式:

$$ d_8(p,q) = \max(\Delta x,\Delta y) + (\sqrt{2} - 1) \cdot \min(\Delta x,\Delta y) $$

如果取两个点:$(1,4)$ 和 $(3,3)$

  • 计算 $d_8$

    • $d_8(1,4) = 4 + (\sqrt{2} - 1) \cdot 1 \approx 4 + 0.4142 = 4.4142$

    • $d_8(3,3) = 3 + (\sqrt{2} - 1) \cdot 3 \approx 3 + 1.2426 = 4.2426$

      ⇒ $d_8(1,4) > d_8(3,3)$

  • 计算欧几里得距离

    • $d_E(1,4) = \sqrt{1^2 + 4^2} = \sqrt{17} \approx 4.1231$

    • $d_E(3,3) = \sqrt{3^2 + 3^2} = \sqrt{18} \approx 4.2426$

      ⇒ $d_E(1,4) < d_E(3,3)$

这个反例说明:$d_8$ 的排序不一定和欧几里得距离的排序一致,因此这里只是借鉴了八邻域的位移,但是两个点之间的距离还是按照欧几里得距离来计算。

scipy.ndimage 库

前面用到的算法用Python写的还是太慢了,尤其是循环嵌套,在2048x2048开始速度会比较慢。

scipy的图像处理底层采用C++来编写,速度会快非常多。

下面代码的scale采用8(后面会说为什么不要使用normalize,而是用固定比例的缩放),同时输出采用16bit(而不是8bit)的灰度图。

import sys
import os
import numpy as np
from PIL import Image
from scipy import ndimage

scale = 8

def load_image_binary(path, threshold=128):
    im = Image.open(path).convert('L')
    arr = np.array(im, dtype=np.uint8)
    inside = arr < threshold
    return inside

def save_sdf_16bit(signed, out_path):
    # 直接转 uint16 保存,不做归一化
    signed = signed * scale + 32767.5
    signed_uint16 = np.clip(signed, 0, 65535).astype(np.uint16)
    out = Image.fromarray(signed_uint16, mode='I;16')
    out.save(out_path)

def fast_signed_sdf(mask):
    dist_inside = ndimage.distance_transform_edt(mask)
    dist_outside = ndimage.distance_transform_edt(~mask)
    return dist_outside - dist_inside  # inside 正,outside 负

def main():
    if len(sys.argv) < 2:
        print("Usage: python fast_sdf.py input1.png [input2.png ...]")
        sys.exit(1)

    for in_path in sys.argv[1:]:
        if not os.path.exists(in_path):
            print("File not found:", in_path)
            continue

        inside = load_image_binary(in_path)
        signed = fast_signed_sdf(inside)

        base, _ = os.path.splitext(in_path)
        out_path = base + "_sdf16.png"
        save_sdf_16bit(signed, out_path)
        print(f"Saved SDF to {out_path}")

if __name__ == "__main__":
    main()

16bit 和 scale

前面使用了归一化距离的方式,但是如果希望合成多张图片,需要将距离的单位统一(因为需要在两张图片之间进行插值)。

第二,如果图片尺寸非常大(2048),那么8bit只有256大小,就不够表示所有距离了,为了避免距离截断导致效果不好,采用16bit的png来存储距离。

2048x2048大小的图片,两点间的最大距离是$2048*1.414=2,896$,同时16bit可以表示的最大范围是+-32767,所以可以选择$scale=8$或者$10$来表示。

scale = 10
# 缩放到 0–65535,32767.5 为边界
unsigned = signed * scale + 32767.5
unsigned = np.clip(unsigned, 0, 65535).astype(np.uint16)

合成算法

怎么合成图片,取决于我们到底想实现什么样的效果。

比如如果是想实现一个 subtraction 的效果,是不是直接把距离做减法就可以了呢?

这种做法确实可以实现在中点处表现为减法,但是最左侧和左右侧的含义实际上是不明确的。

subtraction

# 布尔运算
def sdf_union(d1, d2):
    return np.minimum(d1, d2)  # 并集

def sdf_intersection(d1, d2):
    return np.maximum(d1, d2)  # 交集

def sdf_subtraction(d1, d2):
    return np.maximum(d1, -d2) # A 减 B

类似上面代码的合并的实际含义是:当处在阈值中点的时候,表现为并、交或者减法。

插值思路

compose demo

在游戏中,实质上追求的是这样一个过程,假如有两张图片a和b,我们希望在阈值最大的时候图片就是a的图案,阈值最小的时候图片就是b的图案。

如果是三张图片,那就是最大值是a的图案,中间时就是b的图案,最小值时就是c的图案,以此类推。

这里还需要限制一个条件,那就是它们必须是完全包含的关系,也就是$c \supset b \supset a$,只有这样才可能实现这个过程。

对于图片a和b,由于包含关系,物体a上的任意一个像素,在a和b中的距离值都应该是正数,也就是在物体内部。

对于$b-a$上的一个点$x$,在a中一定是负数$x_a$(外侧),在b中是正数$x_b$。所以$x$这个点在最终图像上的值$x_{final}$,实际上就应该是$x$从$x_b$插值到$x_a$过程中,$x=0$时刻对应的阈值的大小。

比如,如果$x$在${sdf}_a$的值是$-3$,在${sdf}_b$的值是$3$。

那么${x}_{final}$的大小在8bit灰度图下就应该是128,这样刚好在阈值128以下都能点亮这个像素。

所以,核心的思路就是找对应的0点。

蒙特卡洛插值法

这里参考了这篇文章8的算法,通过采样方式来寻找0点。

注意,在Python下这种嵌套循环运行速度是非常慢的,超过2048尺寸的图片不适合用纯Python来写。

import sys
import numpy as np
from PIL import Image

if len(sys.argv) != 3:
    print("用法: python compose2.py a.png b.png")
    sys.exit(1)

# 读取 16bit 灰度图
def load_16bit_gray(path):
    img = Image.open(path)
    arr = np.array(img, dtype=np.uint16)
    return arr

sdf1 = load_16bit_gray(sys.argv[1])
sdf2 = load_16bit_gray(sys.argv[2])

# 检查尺寸一致
if sdf1.shape != sdf2.shape:
    raise ValueError("两张输入图片的尺寸必须相同")

height, width = sdf1.shape
output = np.zeros((height, width), dtype=np.uint16)

THRESHOLD = 32768   # 16bit 对应 0.5 灰度
MAX_VAL = 65535
STEPS = 16

for y in range(height):
    for x in range(width):
        t1 = sdf1[y, x]
        t2 = sdf2[y, x]

        if t1 < THRESHOLD and t2 < THRESHOLD:
            result = 0
        elif t1 > THRESHOLD and t2 > THRESHOLD:
            result = MAX_VAL
        else:
            # 两张图片之间插值
            result = 0
            for i in range(STEPS):
                weight = i / STEPS
                interp = (1 - weight) * t1 + weight * t2
                result += 0 if interp < THRESHOLD else MAX_VAL
            result //= STEPS

        output[y, x] = np.clip(result, 0, MAX_VAL)

# 保存 16bit PNG
out_img = Image.fromarray(output, mode='I;16')
out_img.save("output.png")
print("合成完成: output.png")

最终的算法

插值这个过程完全可以并行处理,首先每个像素之间就毫无关联,其次每个图片之间进行插值其实也没有关联。

使用numpy来进行并行化处理,numpy底层是c++模块,速度非常快。

这里采样点为256个对应灰度图范围,保存为8bit的灰度图(这里没必要16bit)。

import sys
import numpy as np
from PIL import Image

U8 = True

if len(sys.argv) < 3:
    print("用法: python compose.py img1.png img2.png [img3.png ...]")
    sys.exit(1)

def load_16bit_gray(path):
    img = Image.open(path)
    arr = np.array(img, dtype=np.uint16)
    return arr

# 读取所有图
images = [load_16bit_gray(path) for path in sys.argv[1:]]
arr = np.stack(images, axis=0)  # shape: (N, H, W)

if not np.all([img.shape == arr[0].shape for img in images]):
    raise ValueError("所有输入图片的尺寸必须相同")

THRESHOLD = 32768
MAX_VAL = 65535
N, H, W = arr.shape

# 全局 mask
all_below = np.all(arr < THRESHOLD, axis=0)
all_above = np.all(arr > THRESHOLD, axis=0)

output = np.zeros((H, W), dtype=np.float64)
output[all_below] = 0
output[all_above] = MAX_VAL

# 混合部分 mask
mix_mask = ~(all_below | all_above)

# 取混合部分像素
mix_pixels = arr[:, mix_mask].astype(np.float64)  # shape: (N, M)  M = 混合像素个数
M = mix_pixels.shape[1]

# 256 个采样权重
samples = 256
weights = np.linspace(0, 1, samples, endpoint=False)

# 每个采样点属于哪两个图之间
intervals = np.floor(weights * (N - 1)).astype(int)  # shape: (samples,)
local_w = (weights * (N - 1)) - intervals           # shape: (samples,)

# 插值批量计算
interp_results = np.zeros((samples, M), dtype=np.float64)

for k in range(samples):
    i1 = intervals[k]
    i2 = i1 + 1
    val = (1 - local_w[k]) * mix_pixels[i1] + local_w[k] * mix_pixels[i2]
    interp_results[k] = (val >= THRESHOLD) * MAX_VAL

# 平均
res = np.mean(interp_results, axis=0)

# 回写结果
output[mix_mask] = res

if U8:
    # 转为浮点数,映射到 0~255
    output_float = output.astype(np.float32)
    output_scaled = output_float / 65535.0 * 255.0
    output_uint8 = np.clip(np.floor(output_scaled + 0.5), 0, 255).astype(np.uint8)
    # 保存 8 位灰度图
    out_img = Image.fromarray(output_uint8, mode='L')
    out_img.save("output8.png")
    print(f"合成完成: output8.png,合并了 {N} 张图片")
else:
    output = np.clip(output, 0, MAX_VAL).astype(np.uint16)
    out_img = Image.fromarray(output, mode='I;16')
    out_img.save("output16.png")
    print(f"合成完成: output16.png,合并了 {N} 张图片")

效果:

原始图片:

compose origin image

生成的sdf如图,这里因为scale的关系,看起来都比较接近灰色。

compose sdf image

合成sdf:

compose origin sdf demo


  1. wikipedia, Signed distance function, https://en.wikipedia.org/wiki/Signed_distance_function ↩︎

  2. cronrpc, (2025), Signed Distance Field 2D Generator, https://github.com/cronrpc/Signed-Distance-Field-2D-Generator ↩︎

  3. Chris Green. Valve. (2007). Improved Alpha-Tested Magnification for Vector Textures and Special Effects, https://steamcdn-a.akamaihd.net/apps/valve/2007/SIGGRAPH2007_AlphaTestedMagnification.pdf ↩︎

  4. Metric Space, https://en.wikipedia.org/wiki/Metric_space ↩︎

  5. Lisapple, (2017), 8SSEDT , GitHub, https://github.com/Lisapple/8SSEDT ↩︎

  6. Richard Mitton, (2009), Signed Distance Fields, http://www.codersnotes.com/notes/signed-distance-fields/ ↩︎

  7. farteryhr, Correct 8SSEDT, https://replit.com/@farteryhr/Correct8SSEDT#main.cpp ↩︎

  8. 蛋白胨, Unity 卡通渲染 程序化天空盒, https://zhuanlan.zhihu.com/p/540692272 ↩︎