【图像处理】从"图片"到"内存"——你真正理解图像处理的第一天

2 阅读8分钟

在你打开相机拍下一张照片的瞬间,手机里发生了什么?
那张照片在内存里,长什么样子?

这篇文章的目标只有一个:让你从"看图"的视角,切换到"处理内存"的视角。这是图像处理的第一步,也是最关键的认知跃迁。如果你今天只记住一句话,请记住它:

图像处理,本质上不是在“处理图片”,而是在“操作内存中的数据”。


一、图像的本质:一块矩形的数字阵列

打开一张 100×100 的图片,你看到的是一个彩色方块。但计算机看到的是:

一个二维数组,每个格子存着一个像素的颜色信息

更精确地说:

图像 = 二维像素矩阵
     = width × height 个像素
     = 每个像素由若干"通道"组成

以最常见的 RGBA 格式为例:

| 坐标 | R(红) | G(绿) | B(蓝) | A(透明度) | | :-- | :-- | :-- | :-- | :-- | | (0,0) | 255 | 0 | 0 | 255 | | (1,0) | 0 | 255 | 0 | 128 | | (0,1) | 0 | 0 | 255 | 255 |

每个通道的值都在 0~255 之间,因为一个字节(8 bit)能存储的范围恰好是 0~255。


二、像素(Pixel)是什么

Pixel 是 "Picture Element" 的缩写,图像元素。它是图像的最小单位,不可再分。

一个像素本身没有尺寸,是抽象的。当图像显示在屏幕上时,一个像素对应显示器上的一个发光点(或若干个——这涉及到 Retina 屏幕的概念,后面讲)。

RGB 颜色模型

人眼有三种感光细胞,分别对红(Red)、绿(Green)、蓝(Blue)敏感。计算机借鉴了这个原理,用三个数字来描述颜色:

红色  = (255, 0,   0)
绿色  = (0,   255, 0)
蓝色  = (0,   0,   255)
白色  = (255, 255, 255)  // 三色全满
黑色  = (0,   0,   0)    // 三色全无
黄色  = (255, 255, 0)    // 红 + 绿

Alpha 通道(透明度)

Alpha 表示像素的不透明程度:

  • • A = 255:完全不透明(你能看到这个像素的颜色)

  • • A = 0:完全透明(这个像素不可见,透过去看后面的内容)

  • • A = 128:半透明(叠加时与背景各占 50%)

PNG 格式支持 Alpha 通道,JPEG 不支持。 这是格式选择的核心依据之一。


三、内存布局:二维变一维

计算机的内存是一维的(连续的字节序列)。二维的像素矩阵必须被"展平"存储。

行优先存储(Row-major)

最自然的方式:从第 0 行开始,一行一行地存

图像(4×3):

像素(0,0) 像素(1,0) 像素(2,0) 像素(3,0)   ← 第 0 行
像素(0,1) 像素(1,1) 像素(2,1) 像素(3,1)   ← 第 1 行
像素(0,2) 像素(1,2) 像素(2,2) 像素(3,2)   ← 第 2 行

展平到内存:
[p(0,0), p(1,0), p(2,0), p(3,0), p(0,1), p(1,1), ...]

RGBA8888 格式

每个像素占 4 个字节(R/G/B/A 各一个字节),连续排列:

字节布局:
[R₀, G₀, B₀, A₀,  R₁, G₁, B₁, A₁,  R₂, G₂, B₂, A₂, ...]
  ↑                  ↑                  ↑
像素(0,0)          像素(1,0)          像素(2,0)

"8888" 表示每个通道 8 bit,共 32 bit(4 字节)。

关键公式

总字节数 = width × height × 每像素字节数
         = width × height × 4        (RGBA8888)

像素 (x, y) 在数组中的起始字节索引:
  index = (y × width + x) × 4

  R 通道:pixels[index]
  G 通道:pixels[index + 1]
  B 通道:pixels[index + 2]
  A 通道:pixels[index + 3]

举例: 一张 100×100 的图片:

  • • 像素总数 = 10,000

  • • 字节总数 = 40,000(约 40 KB)

  • • 这是原始未压缩的大小

  • • 保存为 PNG 后,全白图可压缩到约 4 KB(压缩率 10:1)

  • • 保存为 JPEG 后,更小


四、为什么是 y × width + x,而不是 x × height + y

这是行优先(Row-major)存储的直接结果:

第 0 行占据字节 0 ~ width-1
第 1 行占据字节 width ~ 2×width-1
第 y 行占据字节 y×width ~ (y+1)×width-1

第 y 行,第 x 列的像素 = 第 y×width+x 个像素
像素索引 × 4 = 字节起始地址

这个公式是所有像素访问的核心,必须烂熟于心。


五、坐标系约定:原点在哪里?

这是图像处理最容易踩坑的地方之一。

本框架约定(也是 UIKit/SwiftUI 的约定)

  • • 原点 (0, 0) 在图像的左上角

  • • x 向右增大

  • • y 向下增大

(0,0) ────────→ x
  │
  │     图像内容
  │
  ↓
  y

注意:数学坐标系和 OpenGL 坐标系的原点在左下角(y 向上),与此不同。CGContext 在不同使用方式下也会有不同表现(下篇内容)。


六、MLBitmap:本框架的核心数据结构

理解了以上知识,再来看代码就非常清晰了:

public struct MLBitmap {

    public let width: Int
    public let height: Int
    
    /// 像素原始数据,RGBA8888,长度 = width × height × 4
    public var pixels: [UInt8]
    
    public static let bytesPerPixel = 4
    
    /// 像素 (x, y) 的字节起始偏移
    @inline(__always)
    func index(x: Int, y: Int) -> Int {
        (y * width + x) * Self.bytesPerPixel
    }
    
    /// 通过二维坐标读写像素
    subscript(x: Int, y: Int) -> Pixel {
        get {
            let i = index(x: x, y: y)
            return Pixel(r: pixels[i], g: pixels[i+1], b: pixels[i+2], a: pixels[i+3])
        }
        set {
            let i = index(x: x, y: y)
            pixels[i]   = newValue.r
            pixels[i+1] = newValue.g
            pixels[i+2] = newValue.b
            pixels[i+3] = newValue.a
        }
    }
}

MLBitmap的本质:

对一块图像内存的结构化封装

几个设计决策的背后逻辑:

为什么用 struct 而非 class

Swift 的结构体(struct)是值类型,赋值时会发生复制(Copy-on-Write)。对于图像处理来说:

  • • 每个 Filter 对图像做变换后,应该返回一个新图像,不修改原始数据

  • • 这符合函数式编程的"不可变性"原则

  • • Swift 的 CoW(写时复制)确保没有真正写入时不会实际复制内存

为什么用 [UInt8] 而非 Data

[UInt8] 是 Swift 原生类型,访问更简洁,下标运算直接。Data 更适合 IO 操作(写文件、网络传输)。两者可以通过 Data(pixels) / [UInt8](data) 互转。

@inline(__always) 是什么?

index(x:y:) 是像素操作的热路径——每处理一个像素都要调用一次。对于 100×100 的图,就是 10,000 次调用。@inline(__always) 告诉编译器强制内联,消除函数调用开销。

一个关键坑:RGB vs RGBA

既然也有RGB,那为何不考虑这种情形呢?理论上可以,实际开发中基本不用。原因在于:

  • • 内存对齐(4字节更高效)

  • • GPU优化(SIMD)

  • • CoreGraphics默认 RGBA/BGRA

为什么必须用 sRGB?

结论:算法统一用sRGB。原因有三:

  • • 保证计算一致性

  • • 不受设备影响

  • • 方便测试

至于显示,系统自动处理(Display P3 等)。

pixels为何不设计为struct数组,而是UInt8数组

区别在于:

[Pixel]

  • • 好理解

  • • 不利于性能

  • • 不适合底层API

[UInt8]

  • • 连续内存

  • • 可直接给 GPU / CGContext

  • • 高性能

最佳实践:

  • • 底层:UInt8

  • • 上层:Pixel 抽象


七、内存大小的直觉感受

| 图像尺寸 | 像素数 | 原始内存(RGBA) | | :-- | :-- | :-- | | 100×100 | 10,000 | 40 KB | | 1920×1080(1080p) | 2,073,600 | ~8 MB | | 3840×2160(4K) | 8,294,400 | ~32 MB | | 12000×9000(中高端单反) | 108,000,000 | ~412 MB |

这就是为什么需要内存防御:一张稍大的 RAW 图片,仅原始像素数据就可以轻松超过 256 MB,导致 OOM(内存溢出)崩溃。

// ImageLoader 中的防御
let requiredBytes = width * height * MLBitmap.bytesPerPixel
guard requiredBytes <= maxMemoryBytes else {
    throw LoadError.memoryTooLarge(bytes: requiredBytes)
}

八、小结

| 概念 | 核心内容 | | :-- | :-- | | 图像本质 | 二维像素矩阵,存储在一维连续内存中 | | 像素 | 最小单元,RGBA 各 8 bit(0~255) | | 内存布局 | 行优先,RGBA8888,index = (y×w+x)×4 | | 坐标系 | 原点左上角,x 向右,y 向下 | | 内存大小 | width × height × 4  字节,大图注意 OOM | | MLBitmap | struct(值类型),[UInt8] 存储 |


思考题

  1. 1. 一张 2000×1500 的 RGBA 图像,第 (100, 200) 个像素的蓝色通道在数组第几个字节?

  2. 2. 如果把行优先改成列优先(Column-major),index(x:y:) 公式会怎么变?遍历图像时性能会有什么影响?

  3. 3. 为什么 JPEG 不支持透明通道,而 PNG 支持?(提示:从格式的设计目标思考)

如果这篇对你有一点启发:
点个赞,让更多人少踩一个坑  
转发给那个正在纠结的人
也欢迎关注我——  
我们一起,把认知变成长期复利。

往期推荐:
iPhone相册背后的图像处理知识(下)
iPhone相册背后的图像处理知识(中)
iPhone相册背后的图像处理知识(上)
一张图了解图像处理的本质
图像到底是什么
图像处理技术概要图
AI时代,软件工程师必备概念全景图