ESC
输入关键词搜索文章
目录

仿射变换与反向映射

图像处理 · 华为笔试 P4277
从正向映射的空洞问题出发,理解为什么需要逆矩阵
3难度(/5)
304通过
1830提交
Part 0 · 题目
题目描述

人脸关键点对齐是人脸识别中的关键步骤,核心操作是仿射变换。题目给定一个 $H_A \times W_A$ 的二维图像矩阵 $A$、一个变换矩阵 $M$ 和输出图像的尺寸 $O_h \times O_w$,要求返回变换后的图像。

变换公式

变换矩阵 $M = \begin{bmatrix} a & b & t_x \\ c & d & t_y \end{bmatrix}$,对原图中的坐标 $(x, y)$,变换后的坐标 $(x', y')$ 为:

$$x' = ax + by + t_x$$
$$y' = cx + dy + t_y$$

用矩阵形式表示:

$$\begin{bmatrix} x' \\ y' \end{bmatrix} = \begin{bmatrix} a & b \\ c & d \end{bmatrix} \begin{bmatrix} x \\ y \end{bmatrix} + \begin{bmatrix} t_x \\ t_y \end{bmatrix}$$

输入输出格式

输入:第一行为 $a\_rows, m\_rows, o\_rows$(图像 $A$ 的行数、矩阵 $M$ 的行数、输出占几行)。接下来 $a\_rows$ 行是图像 $A$,再 $m\_rows$ 行是变换矩阵 $M$,最后一行是输出图像的 $height, width$

输出:将输出图像的二维列表按行展开为一维,空格分隔。

特殊条件:若变换矩阵的线性部分 $(a, b, c, d)$ 不可逆(即行列式 $ad - bc = 0$),则直接返回全 $0$ 图像。
Part 1 · 样例
样例分析

样例 1

输入

3 2 1
10 20 30
40 50 60
70 80 90
0 1 0
-1 0 2
3 3

图像 $A$ 是一个 $3 \times 3$ 矩阵,变换矩阵 $M = \begin{bmatrix} 0 & 1 & 0 \\ -1 & 0 & 2 \end{bmatrix}$

输出30 60 90 20 50 80 10 40 70(即 $\begin{bmatrix} 30 & 60 & 90 \\ 20 & 50 & 80 \\ 10 & 40 & 70 \end{bmatrix}$ 按行展开)

观察这个变换矩阵的效果——$x' = y$$y' = -x + 2$。这实际上是一个顺时针旋转 90° 再加上平移。原图的列变成了新图的行,而且顺序反转。

Part 2 · 核心思路
正向映射 vs 反向映射

实现图像变换时,一个直觉的想法是:遍历原图的每个像素,算出它变换后的位置,然后赋值。这就是正向映射

正向映射的问题

正向映射会导致两类问题:

  1. 空洞:输出图像的某些像素没有被任何原图像素映射到,留下黑色空洞。
  2. 重叠:多个原图像素映射到同一个输出位置,覆盖冲突。

尤其是旋转、缩放等非线性变换,正向映射几乎必然产生空洞——输出图像中出现大量 $0$ 值条纹。

正确的做法是反向映射:遍历输出图像的每个像素 $(x', y')$,反向求解"它应该取原图中哪个位置的值?"

$$\begin{bmatrix} x' \\ y' \end{bmatrix} = \begin{bmatrix} a & b \\ c & d \end{bmatrix} \begin{bmatrix} x \\ y \end{bmatrix} + \begin{bmatrix} t_x \\ t_y \end{bmatrix} \implies \begin{bmatrix} x \\ y \end{bmatrix} = \begin{bmatrix} a & b \\ c & d \end{bmatrix}^{-1} \Bigl( \begin{bmatrix} x' \\ y' \end{bmatrix} - \begin{bmatrix} t_x \\ t_y \end{bmatrix} \Bigr)$$
直觉:想象你在画一幅画(输出图像)。正向映射是"原图每个点飞到画布哪里"——很多格子没人光顾;反向映射是"画布每个格子该从原图哪个位置取色"——每个格子都有颜色。
Part 3 · 数学推导
逆矩阵求解

将正向变换展开为方程组:

$$\begin{cases} x' = ax + by + t_x \\ y' = cx + dy + t_y \end{cases}$$

移项得:

$$\begin{cases} ax + by = x' - t_x \\ cx + dy = y' - t_y \end{cases}$$

这是一个 $2 \times 2$ 线性方程组,用 克拉默法则直接求解——将系数矩阵的第 $j$ 列替换为右端向量,所得行列式除以系数行列式即为 $x_j$。设行列式 $\Delta = ad - bc$

$$x = \frac{d(x' - t_x) - b(y' - t_y)}{\Delta} = \frac{d \cdot x' - b \cdot y' - d \cdot t_x + b \cdot t_y}{\Delta}$$
$$y = \frac{a(y' - t_y) - c(x' - t_x)}{\Delta} = \frac{-c \cdot x' + a \cdot y' + c \cdot t_x - a \cdot t_y}{\Delta}$$

逆变换公式

给定输出坐标 $(x', y')$,原图坐标为:

$$x = \frac{d(x' - t_x) - b(y' - t_y)}{ad - bc}$$
$$y = \frac{-c(x' - t_x) + a(y' - t_y)}{ad - bc}$$

$ad - bc = 0$ 时矩阵不可逆,此时返回全零图像。

求出 $(x, y)$ 后,判断是否在原图范围内($0 \le x < W$$0 \le y < H$)。如果在范围内,取 $A[y][x]$ 作为输出像素值;否则输出像素为 $0$

关于坐标与索引的对应

在图像矩阵中,$A[row][col]$ 对应 $A[y][x]$。即 $x$ 是列方向(水平),$y$ 是行方向(垂直)。这是后续代码中 src_y 取整后作为行索引、src_x 取整后作为列索引的依据。

Part 4 · 算法
算法步骤与复杂度

算法流程

  1. 计算行列式 $\Delta = ad - bc$。若 $\Delta = 0$,返回全零矩阵。
  2. 初始化 $O_h \times O_w$ 的输出矩阵,所有元素为 $0$
  3. 对于输出图像中每个位置 $(x', y')$
    1. 用逆变换公式计算原图坐标 $(x, y)$
    2. $0 \le x < W_A$$0 \le y < H_A$,则 $O[y'][x'] = A[\lfloor y \rfloor][\lfloor x \rfloor]$(最近邻插值)。
  4. 将输出矩阵按行展开输出。
指标分析
时间复杂度$O(O_h \times O_w)$——遍历输出图像的每个像素,每个像素做常数次运算
空间复杂度$O(O_h \times O_w)$——存储输出图像
Part 5 · 代码
完整代码实现(Python)
import sys

def solve():
    data = sys.stdin.read().split()
    idx = 0

    a_rows = int(data[idx]); idx += 1
    m_rows = int(data[idx]); idx += 1
    _o_rows = int(data[idx]); idx += 1

    # 读取图像矩阵 A
    A = []
    for _ in range(a_rows):
        row = list(map(int, data[idx:idx + m_rows])) if m_rows > 0 else []
        # 实际上 A 的列数需要从数据推断
        A.append(row)
        idx += a_rows  # 这不对,需要按实际列数读取

    # 上面的读取逻辑需要修正 —— 重新设计
    pass

# 正确的读取逻辑如下:
def solve_correct():
    data = sys.stdin.read().split()
    idx = 0

    a_rows = int(data[idx]); idx += 1
    m_rows = int(data[idx]); idx += 1
    _o_info = int(data[idx]); idx += 1

    # 读取图像 A(a_rows 行,列数从数据推断)
    # A 的列数 = 后续剩余数据中能读出的列数
    # 但列数并未直接给出,需要用后续解析来确定
    A = []
    for _ in range(a_rows):
        # 每行的元素个数需要推断——但题目数据中每行元素个数一致
        # 读取第一个元素后,在后续 m_rows 行后找到输出尺寸行
        pass

    # 实际上,题目输入格式更简洁:所有数字用空格分隔
    # 第一行: a_rows m_rows 1(最后一项表示输出占几行,通常为1)
    # 接下来 a_rows 行: A 的每行
    # 接下来 m_rows 行: M 的每行
    # 最后 1 行: O_h O_w

上面展示了输入解析的复杂性。实际竞赛中,更清晰的做法是按行读取:

import sys

def main():
    it = iter(sys.stdin.read().strip().split('\n'))
    first = next(it).split()
    a_rows, m_rows, o_rows = int(first[0]), int(first[1]), int(first[2])

    # 读取图像 A
    A = []
    for _ in range(a_rows):
        A.append(list(map(int, next(it).split())))

    # 读取变换矩阵 M
    M = []
    for _ in range(m_rows):
        M.append(list(map(int, next(it).split())))

    # 读取输出尺寸
    last = next(it).split()
    o_h, o_w = int(last[0]), int(last[1])

    a, b, tx = M[0]
    c, d, ty = M[1]

    # 计算行列式
    det = a * d - b * c

    H_A = len(A)
    W_A = len(A[0]) if A else 0

    # 初始化输出
    output = [[0] * o_w for _ in range(o_h)]

    if det == 0:
        # 不可逆,返回全零
        result = []
        for row in output:
            result.extend(row)
        print(' '.join(map(str, result)))
        return

    # 反向映射:遍历输出图像的每个像素
    for oy in range(o_h):
        for ox in range(o_w):
            # 逆变换: 求原图坐标 (sx, sy)
            dx = ox - tx
            dy = oy - ty
            sx = (d * dx - b * dy) / det
            sy = (-c * dx + a * dy) / det

            # 最近邻插值(取整)
            ix = int(sx)
            iy = int(sy)

            # 边界检查
            if 0 <= ix < W_A and 0 <= iy < H_A:
                output[oy][ox] = A[iy][ix]

    # 按行展开输出
    result = []
    for row in output:
        result.extend(row)
    print(' '.join(map(str, result)))

if __name__ == '__main__':
    main()

关于插值的说明

上述代码使用最近邻插值int(sx)int(sy)),即直接取整。题目描述并未明确要求双线性插值,且样例中逆变换结果恰好是整数,因此最近邻插值足以通过所有测试点。

如果需要更高精度的插值,可以改为双线性插值——对周围四个像素按距离加权平均。

Part 5.5 · 常见错误
正向映射的典型错误——逐条分析

很多初学者会写出类似下面的正向映射代码。这段代码包含了四个独立的 bug,每一个都会导致答案错误:

a, m, o = map(int, input().split())
A = [[] for _ in range(a)]
M = [[] for _ in range(m)]
for i in range(a):
    A[i] = list(map(int, input().split()))
for i in range(m):
    M[i] = list(map(int, input().split()))
h, w = map(int, input().split())
O = [[0] * w for _ in range(h)]

for i in range(a):
    for j in range(len(A[0])):
        x = M[0][0] * i + M[0][1] * j + M[0][2]   # ① ②
        y = M[1][0] * i + M[1][1] * j + M[1][2]   # ① ②
        if 0 <= x < h and 0 <= y < w:             # ③
            O[x][y] = A[i][j]                     # ② ④

print(O)                                          # ④

用样例 1 验证:$A = \begin{bmatrix} 10 & 20 & 30 \\ 40 & 50 & 60 \\ 70 & 80 & 90 \end{bmatrix}$$M = \begin{bmatrix} 0 & 1 & 0 \\ -1 & 0 & 2 \end{bmatrix}$,输出 $3 \times 3$

Bug ①:坐标变量 i/j 与 x/y 混淆

变换公式是 $x' = a \cdot x + b \cdot y + t_x$,其中 $x$列方向(水平),$y$行方向(垂直)。

但在循环中,i 是行索引(对应 $y$),j 是列索引(对应 $x$)。代码却把 i(行/$y$)喂给了 $x'$ 公式的第一个参数,把 j(列/$x$)喂给了第二个参数。

结果:两个坐标被互换了。

$A[0][0] = 10$$i=0, j=0$)为例:$x = 0 \cdot 0 + 1 \cdot 0 + 0 = 0$$y = -1 \cdot 0 + 0 \cdot 0 + 2 = 2$。代码得到 $O[0][2] = 10$

但正确结果应该是 $O[2][0] = 10$(行和列恰好反了)。

修正:交换 ij 在公式中的位置:

x = M[0][0] * j + M[0][1] * i + M[0][2]   # j 是列(x),i 是行(y)
y = M[1][0] * j + M[1][1] * i + M[1][2]

Bug ②:计算出的 x' 应为列、y' 应为行,但索引写反

即使修正了 Bug ①,x 算出的是变换后的列坐标 $x'$y 算出的是变换后的行坐标 $y'$。数组访问应该是 O[y][x](先行后列),但代码写的是 O[x][y]

修正O[y][x] = A[i][j]

Bug ③:边界检查方向错误

代码检查 0 <= x < h and 0 <= y < w。但 x 是列坐标,上限应该是 w(宽度);y 是行坐标,上限应该是 h(高度)。恰好写反了。

修正if 0 <= x < w and 0 <= y < h

Bug ④:输出格式错误

代码直接 print(O),输出的是 Python 列表的 repr 形式(如 [[30, 60, 90], ...]),但题目要求的是按行展开、空格分隔的一维输出(如 30 60 90 20 50 80 ...)。

修正

result = []
for row in O:
    result.extend(row)
print(' '.join(map(str, result)))
根因:Bug ①②
  • 都指向同一个认知盲区——
  • 图像坐标 $(x, y)$ 与数组索引 $[row][col]$ 的映射关系$x$ 对应列(第二维),$y$ 对应行(第一维),公式里 j 才是 $x$i 才是 $y$。想清楚这一条,三个 bug 同时消失。

    还有第五个问题:正向映射本身

    即使修完上述四个 bug,正向映射仍然存在根本缺陷:当变换不是一一映射时,输出图像会出现空洞(没有源像素映射到的位置保持为 0)。这正是题目判题系统 WA 的重要原因之一。

    正确的做法始终是反向映射——遍历输出图像,逆变换回原图取值,如 Part 5 的代码所示。

    下面是修正所有 bug 后的正向映射版本(仅当变换是双射时结果正确),供对比理解:

    # ⚠️ 正向映射版本(仅当变换为双射时正确,不推荐)
    import sys
    
    def forward_mapping():
        it = iter(sys.stdin.read().strip().split('\n'))
        first = next(it).split()
        a_rows, m_rows, o_rows = int(first[0]), int(first[1]), int(first[2])
    
        A = [list(map(int, next(it).split())) for _ in range(a_rows)]
        M = [list(map(int, next(it).split())) for _ in range(m_rows)]
        last = next(it).split()
        o_h, o_w = int(last[0]), int(last[1])
    
        a, b, tx = M[0]
        c, d, ty = M[1]
    
        det = a * d - b * c
        O = [[0] * o_w for _ in range(o_h)]
    
        if det == 0:
            print(' '.join(str(v) for row in O for v in row))
            return
    
        H_A, W_A = len(A), len(A[0])
        for i in range(H_A):          # i = 行 = y
            for j in range(W_A):      # j = 列 = x
                col = a * j + b * i + tx   # x' = a*x + b*y + tx,x=j, y=i
                row = c * j + d * i + ty   # y' = c*x + d*y + ty
                if 0 <= col < o_w and 0 <= row < o_h:
                    O[row][col] = A[i][j]  # O[行][列] = O[y'][x']
    
        print(' '.join(str(v) for row in O for v in row))

    对比正向映射和反向映射的核心循环,差异一目了然:

    对比项正向映射(遍历原图)反向映射(遍历输出)
    外层循环for i in range(H_A): for j in range(W_A)for oy in range(o_h): for ox in range(o_w)
    坐标计算用正向公式算 $(x', y')$用逆矩阵算 $(x, y)$
    空洞问题可能有(输出像素未被覆盖)无(每个输出像素都赋值)
    适用条件变换为双射时才正确始终正确
    Part 6 · 验证
    样例手动验证

    样例 1 逐步验证

    输入$A = \begin{bmatrix} 10 & 20 & 30 \\ 40 & 50 & 60 \\ 70 & 80 & 90 \end{bmatrix}$$M = \begin{bmatrix} 0 & 1 & 0 \\ -1 & 0 & 2 \end{bmatrix}$,输出尺寸 $3 \times 3$

    参数$a=0, b=1, t_x=0, c=-1, d=0, t_y=2$。行列式 $\Delta = 0 \times 0 - 1 \times (-1) = 1 \ne 0$

    逆变换$x = \frac{0 \cdot dx - 1 \cdot dy}{1} = -dy$$y = \frac{1 \cdot dx + 0 \cdot dy}{1} = dx$

    其中 $dx = x' - 0 = x'$$dy = y' - 2$。所以 $x = -(y'-2) = 2-y'$$y = x'$

    输出 $(x', y')$原图 $(x, y)$取值
    $(0, 0)$$(2, 0)$$A[0][2] = 30$
    $(1, 0)$$(2, 1)$$A[1][2] = 60$
    $(2, 0)$$(2, 2)$$A[2][2] = 90$
    $(0, 1)$$(1, 0)$$A[0][1] = 20$
    $(1, 1)$$(1, 1)$$A[1][1] = 50$
    $(2, 1)$$(1, 2)$$A[2][1] = 80$
    $(0, 2)$$(0, 0)$$A[0][0] = 10$
    $(1, 2)$$(0, 1)$$A[1][0] = 40$
    $(2, 2)$$(0, 2)$$A[2][0] = 70$

    结果$\begin{bmatrix} 30 & 60 & 90 \\ 20 & 50 & 80 \\ 10 & 40 & 70 \end{bmatrix}$,展开为 30 60 90 20 50 80 10 40 70。✓

    样例 2 验证

    输入$M = \begin{bmatrix} -1 & 0 & 2 \\ 0 & 1 & 0 \end{bmatrix}$,输出尺寸 $3 \times 4$

    行列式 $\Delta = (-1)(1) - 0(0) = -1$。逆变换:$x = \frac{dx}{-1} = -dx = -(x'-2)$$y = \frac{-dy \cdot 0 + (-1) \cdot dy}{-1} = dy = y'$

    $x = 2 - x'$$y = y'$。这是水平翻转 + 右移 2

    输出第 0 行 $(x'=0,1,2,3; y'=0)$:源 $x = 2,1,0,-1$$y=0$$x=-1$ 越界为 $0$,得到 $30, 20, 10, 0$。 ✓

    Part 7 · 延伸
    延伸思考

    为什么人脸对齐需要仿射变换?

    在人脸识别流程中,不同角度、姿态拍摄的人脸关键点位置各不相同。仿射变换能在保持"平行性"(平行线变换后仍平行)的前提下,通过旋转、缩放、平移、剪切,将检测到的关键点对齐到标准模板上,从而消除姿态差异对识别精度的影响。

    仿射变换有 6 个自由度($a, b, c, d, t_x, t_y$),恰好人脸通常有至少 3 个关键点(如双眼和鼻尖),每个点提供 2 个方程,共 6 个方程,可以唯一确定变换矩阵。

    更一般的变换还有透视变换(射影变换),有 8 个自由度,不保持平行性,但在人脸对齐场景中仿射变换已经足够。两者在数值方法上的核心差异同样是——都需要通过反向映射 + 逆矩阵来实现像素级的变换。

    一句话总结:图像变换的核心不是"把像素送出去",而是"把像素请回来"——遍历输出,逆变换回输入取值。