要将合成原始像素转换为可靠的 CGImage,需同时定义其尺寸、行步幅、通道顺序、Alpha 约定、色彩空间和所有权。本指南构建一个经过检查的 BGRA 转换器,然后为结果指定一个明确的 AppKit 显示尺寸。

读者的任务是为 Mac 应用生成的色板、图表或预览创建一张内存中的图像。下面的实现仅接受一种刻意限定的窄格式。另一个决策序列解释了合成 Metal 输出如何到达同一个转换边界。

转换契约必须指定什么?

Apple 的原始数据 CGImage 初始化器期望提供者的字节与提供的图像参数匹配。其宽度和高度以像素为单位;bytesPerRow 描述了每一水平行的内存布局。这些参数描述输入缓冲区,因此请在构建图像前确定它们。Apple 的位图图像初始化器文档。

请将此应用层级契约用于本文中的转换器。其严格的验证规则是使接受的输入无歧义的设计选择。

属性 接受的输入或行为
尺寸 正整数宽度和高度,以像素为单位
组件 每像素四个无符号 8 位组件
内存顺序 连续字节为蓝、绿、红、Alpha
Alpha RGB 分量已由 Alpha 预乘
颜色解释 sRGB
行步幅 至少 width * 4 字节且可被四整除
缓冲区长度 正好 bytesPerRow * height,包含最后一行的填充
行序列 完全保留;无自动垂直翻转
所有权 将输入复制到不可变的自有存储中
失败 对无效输入或构建失败抛出错误
显示 单独选择 AppKit 点尺寸

例如,两像素的一行包含八个有效字节。步幅为十二则在每行后添加四个填充字节。两行共占二十四字节。第二行的第一个像素从字节十二开始。这种布局是刻意填充的:紧凑排列的测试数据无法暴露出将 width * 4 错误替代为所提供步幅的转换器。

要求最后一行后也有完整填充可简化所有权边界。使用较短最后一行分配的生产者必须在调用此转换器前规范化其输出。拒绝长度不匹配也能防止意外接受较大缓冲区,而其预期图像区域不明确。

BGRA、字节序、Alpha 和色彩空间如何组合?

对于此格式,将 CGImageAlphaInfo.premultipliedFirst 与 CGBitmapInfo.byteOrder32Little 结合。Apple 将前者定义为 Alpha 位于最高有效位且颜色分量已乘以 Alpha,后者定义为小端序 32 位布局。这两个定义共同产生按地址递增的字节序列 BGRA。预乘优先 Alpha,32 位小端序位图顺序。

因此,“first” 一词需要字节序设置来确定 Alpha 在内存中的位置。对于不透明红色,此处使用的四个字节是 0, 0, 255, 255。若使用这些设置提供 RGBA 字节,会交换红和蓝。更改元数据无法重排现有缓冲区。

预乘同样具体。在此说明性的 8 位约定中,Alpha 为 128 的纯红色在 BGRA 内存中变为约 0, 0, 128, 128。转换器检查每个存储的颜色分量不大于 Alpha。这能捕获其所选归一化预乘格式的不可能值,尽管无法证明生产者如何计算每个分量。

仅当字节具有该预期解释时,才指定 CGColorSpace.sRGB。Apple 规定了 sRGB 色度学及其非线性传输函数。仅有色彩空间标签无法将线性 RGB 分量值转换为 sRGB 值。Apple 的 sRGB 定义。

此转换器执行验证和图像构建。如果生产者输出直 Alpha、RGBA 顺序、浮点分量或线性颜色值,请在此边界前添加显式规范化步骤,或为该格式实现单独记录的契约。

Swift 如何用自有存储构建图像?

对于小型生成的桌面图像,请使用复制边界。Apple 文档称 CFDataCreate 总是将提供的缓冲区复制到内部存储中,而 CGDataProvider(data:) 读取一个 CFData 对象。CGImage 初始化器会保留其提供者。这避免了将图像绑定到临时数组指针。将字节复制到 CFData,创建基于 CFData 的提供者。

以下是一个完整的 macOS Swift 命令行程序。将其保存为 main.swift 并在安装了开发者工具的 Mac 上运行 swift main.swift。它构建两个图像对象并打印其尺寸;不创建窗口。该 Swift 程序经检查但无法在 Linux 环境中编译。其测试数据的字节数、偏移量和预乘分量边界已用 Python 单独验证。

import AppKit
import CoreGraphics
import CoreFoundation

 enum PixelError: Error {
    case invalidDimensions, arithmeticOverflow
    case invalidStride, invalidByteCount
    case invalidPremultipliedPixel
    case constructionFailed
}

func makeBGRAImage(
    bytes: [UInt8], width: Int, height: Int, bytesPerRow: Int
) throws -> CGImage {
    guard width > 0, height > 0 else {
        throw PixelError.invalidDimensions
    }
    let (activeRowBytes, rowOverflow) =
        width.multipliedReportingOverflow(by: 4)
    guard !rowOverflow else { throw PixelError.arithmeticOverflow }
    guard bytesPerRow >= activeRowBytes,
          bytesPerRow % 4 == 0 else {
        throw PixelError.invalidStride
    }
    let (requiredBytes, sizeOverflow) =
        bytesPerRow.multipliedReportingOverflow(by: height)
    guard !sizeOverflow else { throw PixelError.arithmeticOverflow }
    guard bytes.count == requiredBytes else {
        throw PixelError.invalidByteCount
    }

    for y in 0..<height {
        for x in 0..<width {
            let i = y * bytesPerRow + x * 4
            let alpha = bytes[i + 3]
            guard bytes[i] <= alpha,
                  bytes[i + 1] <= alpha,
                  bytes[i + 2] <= alpha else {
                throw PixelError.invalidPremultipliedPixel
            }
        }
    }

    let copiedData: CFData? = bytes.withUnsafeBufferPointer { buffer in
        CFDataCreate(nil, buffer.baseAddress, requiredBytes)
    }
    guard let data = copiedData,
          let provider = CGDataProvider(data: data),
          let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) else {
        throw PixelError.constructionFailed
    }
    let info = CGBitmapInfo.byteOrder32Little.union(
        CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
    )
    guard let image = CGImage(
        width: width, height: height,
        bitsPerComponent: 8, bitsPerPixel: 32,
        bytesPerRow: bytesPerRow,
        space: colorSpace, bitmapInfo: info,
        provider: provider, decode: nil,
        shouldInterpolate: false, intent: .defaultIntent
    ) else {
        throw PixelError.constructionFailed
    }
    return image
}

// Two active pixels and four padding bytes per row.
let syntheticBytes: [UInt8] = [
    0, 0, 255, 255,  0, 255, 0, 255,  0, 0, 0, 0,
    255, 0, 0, 255,  0, 0, 128, 128,  0, 0, 0, 0
]

do {
    let cgImage = try makeBGRAImage(
        bytes: syntheticBytes, width: 2, height: 2, bytesPerRow: 12
    )
    let appKitImage = NSImage(
        cgImage: cgImage, size: NSSize(width: 2, height: 2)
    )
    precondition(cgImage.width == 2 && cgImage.height == 2)
    print("Pixels: \(cgImage.width) x \(cgImage.height)")
    print("Points: \(appKitImage.size)")
} catch {
    fatalError("Synthetic image conversion failed: \(error)")
}

经过检查的乘法运行在任何索引或缓冲区复制之前。一旦长度和步幅检查通过,每个有效像素都位于提供的数组内。填充被保留但不参与像素验证。错误情况区分畸形尺寸、步幅、长度、Alpha 值和构建失败;实际应用可通过现有错误处理呈现或记录这些错误。

测试数据的第一行存储不透明红和不透明绿。第二行存储不透明蓝和约半透明红。decode: nil 请求无解码重映射,而 shouldInterpolate: false 请求无像素平滑的构建。这些参数不调整大小也不重写输入。Apple 的图像构建参数。

作为后续优化,Apple 还提供了一个带释放回调的直接访问提供者初始化器。该路径需要单独的分配-生命周期契约:在 Core Graphics 使用期间保持分配有效,并通过回调释放。将其视为另一种实现,其所有权必须显式设计。直接访问数据提供者初始化器。

像素尺寸如何成为 AppKit 显示尺寸?

将光栅尺寸和逻辑尺寸作为分离的输入。Apple 区分以点为单位的表示尺寸和以像素为单位的宽高。NSImage(cgImage:size:) 接受所需图像尺寸;传递 .zero 使其数值上采用源图像的像素尺寸。图像表示尺寸,AppKit 的 CGImage 初始化器。

作为一个说明性尺寸决策,一个 400 × 200 像素的光栅若打算每点提供两个像素,其 NSImage 尺寸应为 200 × 100 点。该计算表达了预期的逻辑尺寸。底层 CGImage 仍为 400 × 200 像素;包装器不创建额外采样。

程序为其两像素测试数据的每个维度选择两点。对于更大的预览,请选择适合视图的显式逻辑尺寸。当下游操作需要精确光栅尺寸时,保留 CGImage 可用,因为 Apple 建议不要假设 NSImage 包装器的细节,除其与源的绘制等效性外。

当合成像素来自 Metal 时有何变化?

Metal 在 CPU 转换器运行前增加了一个就绪契约。Apple 将 .bgra8Unorm 定义为 BGRA 顺序的四个归一化无符号 8 位分量。其对应的 .bgra8Unorm_srgb 还指定了 sRGB 与线性空间之间的转换。因此仅匹配通道顺序不足以确保纹理内容满足本文的颜色和预乘 Alpha 约定。BGRA8 归一化格式,BGRA8 sRGB 格式。

对合成的单采样 2D 纹理(零级多级纹理)使用此序列:

  1. 确定纹理的像素格式、颜色解释、Alpha 约定和预期行方向。
  2. 完成写入纹理的 GPU 工作,并在接受其输出前验证成功完成。
  3. 若纹理使用托管存储,编码所需的 GPU 到 CPU 同步并等待该同步完成。
  4. 若纹理使用私有存储,在读取前将其 blit 到合适的 CPU 可访问纹理中。
  5. 分配一个经过检查的目标缓冲区,使用其实际目标步幅复制请求区域,然后将生成的字节通过 CPU 契约。

这些步骤反映了两个不同的文档化要求。getBytes 在 CPU 上立即复制,但不与 GPU 纹理操作同步,且 Apple 明确禁止将其直接用于私有纹理。单独地,synchronize(resource:) 更新托管资源的 CPU 副本;CPU 访问在该命令完成后进行。纹理读回要求,托管资源同步。

为特定读回操作选择目标步幅并在转换过程中保留它。对于窄 BGRA 契约,每个有效行需要每像素四字节。blit 暂存布局可能需要与直接 CPU 读回布局不同的填充;在每个边界记录步幅,而非假设所有缓冲区共享一个间距。

如何诊断契约不匹配?

在测试更大的生成图形前,使用四像素测试数据作为决策辅助。以下诊断是从其已知值和布局推断得出,而非来自应用的测量结果。

观察 首先检查
红蓝交换位置 实际通道字节与 Alpha 及字节序标志对比
第一行看似合理;后续行偏移 源步幅、目标步幅和填充
半透明红显得过亮或过暗 预乘和颜色解释
两行模式垂直位置交换 生产者行顺序和绘制变换
光栅正确但逻辑尺寸错误 AppKit 点尺寸
CPU 生成的输入有效;GPU 生成的输入无效 GPU 完成、存储模式、同步和读回布局

关于方向,此转换器承诺逐字节保留行。在实际目标视图中检查不对称测试数据。若结果相对于预期设计垂直翻转,请在文档化的规范化边界修正行顺序,或修正绘制变换。保持该决策显式,以免两个阶段都翻转图像。

完整的原始像素转换契约让你识别每个阶段接收和保证的内容:生产者提供定义的像素,转换器验证其布局并拥有一份副本,AppKit 接收一个刻意的逻辑尺寸。用一个小型合成测试数据建立这些边界,然后将相同的尺寸、步幅、颜色、Alpha 和生命周期规则带入更大的图像中。

来源