【图像处理】坐标系与图像加载——UIImage 是怎么变成内存像素的

0 阅读8分钟

"图像加载"听起来简单——打开文件,读进来就行了。 但如果坐标系搞反了,你的图会上下颠倒,像素操作会全部错位。 这一天,我们彻底搞清楚 UIImage → CGImage → CGContext → MLBitmap 的每一步。


一、Apple 图像体系:三层架构

在 iOS 开发中,"图像"有三个不同层次的抽象:

UIImage         ← 高层,UIKit 的图像对象,包含显示信息(scale、方向)
    ↓
CGImage         ← 中层,Core Graphics 的原始图像,与硬件更接近
    ↓
像素数据        ← 底层,CGContextCGDataProvider 操作的原始字节

UIImage 不等于像素

let image = UIImage(named: "photo.jpg")

此时 UIImage 内部存储的是压缩数据(JPEG/PNG 的编码字节流),还没有解码成像素。只有当你实际需要像素(比如显示到屏幕、或通过 CGContext 读取)时,才会触发解码。

CGImage 是像素的描述符

CGImage 描述了图像的元信息(宽、高、颜色空间、位深、每行字节数),并持有实际像素数据的引用,但并不一定就是你想要的格式(颜色空间可能是 Display P3,Alpha 可能是 premultiplied,字节序可能是小端)。


二、颜色空间:同样的数字,不同的颜色

同一个 (255, 0, 0),在不同颜色空间下,显示出来的红色不完全相同

颜色空间特点典型场景
sRGB互联网标准,覆盖人眼约 35%Web、普通显示器
Display P3比 sRGB 宽约 25%,更艳丽iPhone 8 以后的屏幕
Adobe RGB设计/印刷行业专业摄影
Lab感知均匀,与人眼距离线性相关图像差异比较

问题:如果你直接读取 CGImage 的像素字节,而该 CGImage 是 Display P3 颜色空间的,你拿到的数字放到 sRGB 算法里计算,结果会偏差。

解决方案:通过 CGContext 重新绘制,强制转换到统一的 sRGB:

guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) else { ... }

guard let context = CGContext(
    data: baseAddress,
    width: width, height: height,
    bitsPerComponent: 8,
    bytesPerRow: bytesPerRow,
    space: colorSpace,          // ← 强制输出到 sRGB
    bitmapInfo: bitmapInfo
) else { ... }

context.draw(cgImage, in: CGRect(...))
// 此时 baseAddress 里的字节一定是 sRGB + RGBA8888 格式

这一步是 ImageLoader 的核心:不是直接读字节,而是通过 CGContext 重新绘制,完成颜色空间归一化


三、坐标系的陷阱:为什么图像会上下颠倒

这是整个图像处理框架里最复杂、最容易出错的地方。

CGContext 的坐标系

Core Graphics(CG)的坐标系原点在左下角,y 轴向上:

y ↑
  │
  │   (CGContext 坐标系)
  │
  └────────→ x
(0,0)

但 UIKit / SwiftUI 的坐标系原点在左上角,y 轴向下:

(0,0)────────→ x
  │
  │   (UIKit 坐标系)
  │
  ↓ y

CGContext.draw(cgImage) 做了什么

CGContext.draw(cgImage, in: rect) 会把 CGImage 绘制到 CGContext 中。

关键事实:CGContext.draw 会按照 CG 坐标系绘制,即 CGImage 的第 0 行(视觉顶部)会被绘制到 Context 的底部(y 最大处)。

听起来好像会翻转?但实际不会,原因是:

当你用 CGContext(data: buffer, ...) 构造 Context 时,指定了 data 指针。这个 Context 不对应任何屏幕或窗口,它只是一个"内存 Context"。

对于内存 Context,draw(cgImage) 的实际行为是:

  • CGImage 的 row 0(视觉顶部)→ buffer 的第 0 行(内存起始处)

这不是 CG 坐标系的翻转结果,而是 CGContext + CGDataProvider 共同遵守的"内存光栅约定"(raster convention):内存第 0 行 = 图像视觉顶部。

加了 flip 变换反而出错

很多教程会教你"在 CGContext 中加 flip 变换来修正坐标系":

// ⚠️ 这段代码是错误的(在我们的场景下)
context.translateBy(x: 0, y: CGFloat(height))
context.scaleBy(x: 1, y: -1)
context.draw(cgImage, in: CGRect(...))

这个变换的逻辑是:先把坐标系翻转,让 draw 时的"视觉顶部"对应内存顶部。

但问题是:这在 macOS 的屏幕渲染场景是正确的,在内存 Context 场景反而会让图像上下颠倒

加了 flip 之后,CGImage row 0 会被写到 buffer 的末尾,导致 bitmap[0, 0] 读到的是视觉上的左下角,而不是左上角。

结论

内存 CGContext + CGContext.draw(cgImage) + 不加 flip
= CGImage row 0 → buffer row 0 = 视觉左上角
= 正确 ✅

加了 flip 变换
= CGImage row 0 → buffer 末尾 = 视觉左下角被映射到 (0,0)
= 图像倒置 ❌

正确的 ImageLoader 实现

// ─── 正确做法:不加任何坐标变换 ───────────────────────────
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
// CGImage row 0(视觉顶部)自然对应 buffer row 0
// bitmap[0, 0] = 图像左上角像素 ✅

四、Premultiplied Alpha:什么是"预乘 Alpha"

Alpha 通道有两种存储方式:

Straight Alpha(直接 Alpha)

像素颜色 = (R, G, B, A)
实际显示 = 将 RGB 按 A/255 的比例混合到背景

Premultiplied Alpha(预乘 Alpha)

像素颜色 = (R × A/255, G × A/255, B × A/255, A)
           ↑            ↑            ↑
         已经预乘好了

例子:一个半透明红色像素:

  • Straight:(255, 0, 0, 128)
  • Premultiplied:(128, 0, 0, 128)

为什么使用 Premultiplied?

  1. 合成更快:显示时不需要额外做 R × A/255 的运算
  2. 减少过采样伪影:插值(如缩放时)更准确

本框架使用 premultipliedLast

premultipliedLast = 预乘 Alpha + 通道顺序为 RGBA(Alpha 在最后)。

这是 UIKit 的标准格式,与 jpegData() / pngData() 的输入/输出保持一致。


五、统一的 bitmapInfo:单一可信来源

ImageLoader(写入)和 ImageExporter(读出)必须使用完全相同的 bitmapInfo,否则:

Loader 用Exporter 用结果
premultipliedLastpremultipliedLast颜色正确 ✅
premultipliedLastpremultipliedFirstARGB vs RGBA 错乱,颜色偏移 ❌
byteOrder32BigbyteOrder32Little字节序颠倒,颜色错误 ❌

本框架将 bitmapInfo 提取为 MLBitmap.bitmapInfo 常量,两端共同引用:

// MLBitmap.swift — 单一可信来源(SSOT)
public static let bitmapInfo: CGBitmapInfo = CGBitmapInfo(rawValue:
    CGImageAlphaInfo.premultipliedLast.rawValue |   // RGBA 通道顺序
    CGBitmapInfo.byteOrder32Big.rawValue            // 大端字节序
)

// ImageLoader 引用
let bitmapInfo = MLBitmap.bitmapInfo.rawValue

// ImageExporter 引用
let bitmapInfo = MLBitmap.bitmapInfo

六、CGDataProvider vs CGContext:导出时的对称性

加载:用 CGContext.draw() → 内存写入 导出:用 CGDataProvider → 从内存读出

// ImageExporter.toUIImage()
let bitmapInfo = MLBitmap.bitmapInfo
let data = Data(bitmap.pixels)
guard let provider = CGDataProvider(data: data as CFData) else { return nil }

guard let cgImage = CGImage(
    width: bitmap.width, height: bitmap.height,
    ...
    provider: provider,
    ...
) else { return nil }

CGDataProvider 的约定与 CGContext 内存 raster 约定相同:

  • data 第 0 字节 = 图像视觉顶部第 0 行
  • 与 ImageLoader 对称,不需要任何额外翻转

一致性测试验证

func testCoordinateOriginIsTopLeft() throws {
    var bmp = MLBitmap(width: 4, height: 4, filling: .white)
    bmp[0, 0] = .red   // 左上角设为红色

    // 导出
    ImageExporter.savePNG(bmp, to: url)

    // 重新加载
    let reloaded = try ImageLoader.load(from: UIImage(contentsOfFile: url.path)!)

    // 验证:(0,0) 仍然是红色
    XCTAssertEqual(reloaded[0, 0], .red)   // ✅ 证明坐标系一致
    XCTAssertEqual(reloaded[3, 3], .white) // ✅ 右下角仍为白色
}

七、完整加载流程图

UIImage(包含压缩数据)
    │
    ↓ image.cgImage
CGImage(图像描述符,可能是任意颜色空间)
    │
    ↓ CGContext.draw()(颜色空间归一化到 sRGB)
内存缓冲区 [UInt8]RGBA8888sRGB,行优先)
    │
    ↓ MLBitmap(width:height:pixels:)
MLBitmap(框架统一数据结构)

每一步的核心作用:

  1. UIImage → CGImage:解封装,获取底层图像描述
  2. CGImage → CGContext:解码 + 颜色空间转换 + Alpha 格式统一
  3. CGContext 内存 → MLBitmap:包装成可安全操作的 Swift 值类型

八、安全防御:在问题发生前拦截

// 防御 1:GPU 纹理上限(Metal 最大支持 16384px)
guard width <= maxDimension && height <= maxDimension else {
    throw LoadError.dimensionTooLarge(width: width, height: height)
}

// 防御 2:内存预估(峰值约为 pixels 数组的 2 倍)
let requiredBytes = width * height * MLBitmap.bytesPerPixel
guard requiredBytes <= maxMemoryBytes else {
    throw LoadError.memoryTooLarge(bytes: requiredBytes)
}

// 防御 3:CGContext 创建失败(通常是 OS 内存不足)
var contextCreationFailed = false
pixels.withUnsafeMutableBytes { buffer in
    guard let ctx = CGContext(...) else {
        contextCreationFailed = true; return
    }
    ctx.draw(cgImage, ...)
}
if contextCreationFailed { throw LoadError.contextCreateFailed }

为什么峰值是 2 倍?

  • pixels 数组:width × height × 4 字节
  • CGContext 内部缓冲区:又一个 width × height × 4 字节
  • 两者同时存在于内存中,峰值 = 2×

九、小结

知识点核心结论
UIImage vs CGImageUIImage 是高层对象,CGImage 是底层描述符
颜色空间通过 CGContext 统一归一化到 sRGB
坐标系内存 CGContext 不需要 flip,row 0 = 视觉顶部
Premultiplied Alpha预乘 Alpha 合成更快,是 iOS 标准格式
bitmapInfo 统一Loader/Exporter 必须用相同参数,提取为常量
CGDataProvider导出时与 CGContext 对称,不需要额外翻转

思考题

  1. 如果 UIImage 的 imageOrientation 不是 .up(比如手机竖拍的照片),直接取 cgImage 会有什么问题?如何修复?
  2. 为什么我们用 byteOrder32Big 而不是 byteOrder32Little?两者的字节排列有什么区别?
  3. premultiplied 格式下,如果 Alpha = 0(完全透明),RGB 三个通道的值应该是多少?为什么?

上一期参考答案:1. (200 × 2000 + 100) × 4 + 2 = 1,600,802;2. 改为 (x × height + y) × 4,遍历时缓存命中率下降,性能变差;3. JPEG 专为照片设计,用 YCbCr 颜色空间做有损压缩,Alpha 通道在其设计中没有位置。

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

往期推荐:

从"图片"到"内存"——你真正理解图像处理的第一天
iPhone相册背后的图像处理知识(下)
iPhone相册背后的图像处理知识(中)
iPhone相册背后的图像处理知识(上)
一张图了解图像处理的本质
图像到底是什么
图像处理技术概要图
AI时代,软件工程师必备概念全景图