comfyui 的 MaskEditor 保存遮罩的图片如何实现的

情况分析

comfyui 中可以通过 Load Image 节点右键打开 Open in MaskEditor 来实现对图片进行涂抹遮罩,保存后可以通过 mask 管道为别的节点提供数据。

但是在 API 接口处理中,这个 Load Image 节点只接收一个文件参数,并不是两个文件,原始照片和涂抹后的遮罩照片。该节点只需要传递一个包含遮罩的图片,这个包含遮罩信息图片,如果在不了解原理的情况下很容易搞错方向。

你可以将经过 comfyui 处理过的图片提取出来分析,它存储在 /root/ComfyUI/input/clipspace 目录中。

一张图有三种不同的状态,可以简单总结下:

因为涂抹区域透明度的原因,根据所呈现位置的背景颜色不同,所以最终呈现的预览图片也不同。在右侧的文件夹预览似乎微软忽略了透明度,所以呈现出来的就是未经涂抹的原始图片。

当你把这张图片通过 Photoshop 打开时,能够看到所涂抹的区域是透明的,并不意味着 RGB 通道的数据丢失了,而是被 alpha 通道计算后覆盖了。

所以, 在实现类似将涂抹区域和原始图片合二为一时,不能将原本的 RGB 数据进行覆盖,这样会丢失数据,只应该操作 alpha 通道,通过明暗度(0~255)来填充用户涂抹的区域坐标,涂抹为黑(0),非涂抹为(255), 中间取值为渐变。

NRGBA & RGBA

那么接下来只需要写程序将用户涂抹的图层坐标遍历后写入到原始图片的 alpha 通道就行了。

但我在这一步遇到了非常多的问题,反复测试也没有实现与从 comfyui 预期的图片数据,当图片打开时看起来和 comfyui 生成的一样,但通过工作流跑的时候结果是错误的。

经过反复折腾测试,最终解决了问题。

在读取和二次绘制图片时,需要将颜色模式设置为 NRGBA(非预乘),而非 RGBA(预乘)。

只有当处于 NRGBA 颜色模式下,你给予 alpha 通道写入的数据,才不会在编码时与其他 RGB 通道的数据相乘覆盖掉有效数据。

简而言之,当用户涂抹区域坐标 x1,y1 要在 alpha 通道写入 0 数据,他们在不同颜色模式的编码后分别为:

NRGBA (非预乘)

预期:R: 100, G: 100, B: 100, A: 0 保存编码后: R: 100, G: 100, B: 100, A: 0’

RGBA(预乘)

预期:R: 100, G:100, B:100, A: 0 保存编码后: R: 0, G: 0, G:0 , A: 0

对比发现同一坐标在不同颜色模式下,NRGBA 能够将原始色彩记录下来,而 RGBA 会将透明度相乘最终写入到 RGB 中代替原本的数据。知道这一问题后豁然开朗。

在 golang 中通过 image.NewNRGBA(bounds) 创建 NRGBA 图片,通过 nrgba.At().RGBA() 获取色彩,通过 color.NRGBA{} 来指定色彩。

示例代码如下, 将为一个原始图片创建一个居中矩形涂抹区域遮罩:

// createMaskDemo 创建一个在图片中心带有矩形透明遮罩的演示图
func createMaskDemo(inputPath, outputPath string) error {
	// 打开输入图片
	file, err := os.Open(inputPath)
	if err != nil {
		return fmt.Errorf("无法打开输入文件: %v", err)
	}
	defer file.Close()

	// 解码图片
	img, _, err := image.Decode(file)
	if err != nil {
		return fmt.Errorf("无法解码图片: %v", err)
	}

	// 获取图片尺寸
	bounds := img.Bounds()
	width, height := bounds.Dx(), bounds.Dy()

	// 创建 NRGBA 图片(非预乘 Alpha,与 ComfyUI 兼容)
	nrgba := image.NewNRGBA(bounds)
	draw.Draw(nrgba, bounds, img, bounds.Min, draw.Src)

	// 计算矩形遮罩的位置和大小 (中心位置,宽高各为原图的 1/2)
	maskWidth := width / 2
	maskHeight := height / 2
	maskX := (width - maskWidth) / 2
	maskY := (height - maskHeight) / 2

	// 在中心区域应用遮罩 - 使用纯黑色 (ComfyUI 兼容格式)
	for y := maskY; y < maskY+maskHeight; y++ {
		for x := maskX; x < maskX+maskWidth; x++ {
			if x >= 0 && x < width && y >= 0 && y < height {
				originalColor := nrgba.At(x, y)
				r, g, b, _ := originalColor.RGBA()
				nrgba.Set(x, y, color.NRGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), 0})
			}
		}
	}

	// 保存结果
	return saveImage(nrgba, outputPath)
}

需要注意的是:

  • color.RGBA() 方法返回的值: 使用 16 位表示 (0-65535)

  • color.NRGBA 构造函数需要的值: 使用 8 位表示 (0-255)

  • 所以需要通过 >> 8 将 16 位值转换为 8 位值

同样的,将一个包含涂抹通道的图片拆分为原始图片和涂抹遮罩的两张图片代码如下:

// extractImageAndMask 从包含 Alpha 通道的图片中分离 RGB 和 Alpha 通道
func extractImageAndMask(inputPath, baseOutputPath, maskOutputPath string) error {
	// 打开输入图片
	file, err := os.Open(inputPath)
	if err != nil {
		return fmt.Errorf("无法打开输入文件: %v", err)
	}
	defer file.Close()

	// 解码图片
	img, _, err := image.Decode(file)
	if err != nil {
		return fmt.Errorf("无法解码图片: %v", err)
	}

	bounds := img.Bounds()

	// 创建 RGB 图片(NRGBA 格式,完全不透明) 和 Alpha 通道图片(灰度)
	rgbImg := image.NewNRGBA(bounds)
	alphaImg := image.NewGray(bounds)

	// 遍历每个像素
	for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
		for x := bounds.Min.X; x < bounds.Max.X; x++ {
			originalColor := img.At(x, y)

			// 获取 NRGBA 值 - 这里重要的是要正确处理非预乘 Alpha
			var r8, g8, b8, a8 uint8

			// 检查原始图片类型,以便正确提取颜色值
			switch c := originalColor.(type) {
			case color.NRGBA:
				// 如果是 NRGBA,直接使用
				r8, g8, b8, a8 = c.R, c.G, c.B, c.A
			case color.RGBA:
				// 如果是 RGBA,需要转换
				r8, g8, b8, a8 = c.R, c.G, c.B, c.A
			default:
				// 其他格式,通过 RGBA()方法获取后转换
				r, g, b, a := originalColor.RGBA()
				r8 = uint8(r >> 8)
				g8 = uint8(g >> 8)
				b8 = uint8(b >> 8)
				a8 = uint8(a >> 8)
			}

			// RGB 图片:保存 RGB 颜色,Alpha 设为 255(完全不透明)
			rgbImg.Set(x, y, color.NRGBA{r8, g8, b8, 255})

			// Alpha 图片:将 Alpha 通道作为灰度值保存
			alphaImg.Set(x, y, color.Gray{a8})
		}
	}

	// 保存 RGB 图片
	err = saveImage(rgbImg, baseOutputPath)
	if err != nil {
		return fmt.Errorf("保存 RGB 图片失败: %v", err)
	}

	// 保存 Alpha 通道图片
	err = saveImage(alphaImg, maskOutputPath)
	if err != nil {
		return fmt.Errorf("保存 Alpha 通道图片失败: %v", err)
	}

	return nil
}

RGBA 写入的数据不能使用 RGBA 读取

接下来,还需要搞懂一件事,既然 RGBA 会丢失数据,NRGBA 会保留数据。

那到底这个数据丢在什么时刻,写入 RGBA{100,0,0,0}时,会原模原样存储,只是读取时按照 NRGB 模式读取,是否能正确读取到数据呢?

func main() {
	// 创建一个 NRGBA 图片,写入测试数据
	img := image.NewNRGBA(image.Rect(0, 0, 1, 1))

	// 写入数据:R=100, G=100, B=100, A=0
	img.Set(0, 0, color.RGBA{R: 100, G: 0, B: 0, A: 0})

	// 保存到文件
	file, _ := os.Create("test.png")
	png.Encode(file, img)
	file.Close()

	// 重新读取文件
	file, _ = os.Open("test.png")
	loadedImg, _, _ := image.Decode(file)
	file.Close()


	// 方式 1:当作 NRGBA 读取
	if nrgba, ok := loadedImg.(*image.NRGBA); ok {
		c1 := nrgba.NRGBAAt(0, 0)
		fmt.Printf("NRGBA 读取: (%d,%d,%d,%d) \n",
			c1.R, c1.G, c1.B, c1.A)
	}

	// 方式 2:通过 RGBA()方法读取(标准化)
	c1 := loadedImg.At(0, 0)

	r1, g1, b1, a1 := c1.RGBA()

	fmt.Printf("RGBA()读取: (%d,%d,%d,%d)\n",
		r1>>8, g1>>8, b1>>8, a1>>8)
}

运行后输出

NRGBA 读取: (0,0,0,0) 
RGBA()读取: (0,0,0,0)

可以看到使用 RGB 色彩模式保存时会丢失数据,即时使用 NRGB 模式读取也无济于事。

如果将上述代码中的写入色彩改为 NRGBA 的结构:color.NRGBA

func main() {
	// 创建一个 NRGBA 图片,写入测试数据
	img := image.NewNRGBA(image.Rect(0, 0, 1, 1))

	// 写入数据:R=100, G=100, B=100, A=0
	img.Set(0, 0, color.NRGBA{R: 100, G: 0, B: 0, A: 0})

	// 保存到文件
	file, _ := os.Create("test.png")
	png.Encode(file, img)
	file.Close()

	// 重新读取文件
	file, _ = os.Open("test.png")
	loadedImg, _, _ := image.Decode(file)
	file.Close()

	// 方式 1:当作 NRGBA 读取
	if nrgba, ok := loadedImg.(*image.NRGBA); ok {
		c1 := nrgba.NRGBAAt(0, 0)
		fmt.Printf("NRGBA 读取: (%d,%d,%d,%d) \n",
			c1.R, c1.G, c1.B, c1.A)
	}

	// 方式 2:通过 RGBA()方法读取(标准化)
	c1 := loadedImg.At(0, 0)

	r1, g1, b1, a1 := c1.RGBA()

	fmt.Printf("RGBA()读取: (%d,%d,%d,%d)\n",
		r1>>8, g1>>8, b1>>8, a1>>8)
}

运行后, 发现 NRGBA 写入的数据,RGBA 也无法正确读取,即在读取时也会相乘。

NRGBA 读取: (100,0,0,0) 
RGBA()读取: (0,0,0,0)

通过对比文件也可以观测到区别: {R: 100, G: 0, B: 0, A: 0}

这个问题搞明白了,无论如何要正确使用 RGBA 和 NRGBA,不能在 RGBA 色彩中读取 NRGBA 的数据。

如果你的透明度是非 0,理论可以额外通过计算得出原始的数据,但是由于存储的值是 int 而非 float,硬存 float 则会丢弃数据,总之是无法还原出原始数据的。

NRGBA 使用 16 位色彩灰度存储

上文中,NRGBA 存储时需要将 RGBA 的 16 位数据转换为 8 位,会丢失一部分色彩深度,是否 NRGBA 是否可以存储 16 位的数据呢?

可以,但要使用 NRGBA64 相关的函数和类型

type NRGBA struct {
    R, G, B, A uint8  // 只能存储 0-255
}
// 16 位颜色类型
type NRGBA64 struct {
    R, G, B, A uint16  // 可以存储 0-65535
}

type RGBA64 struct {
    R, G, B, A uint16  // 预乘 Alpha 的 16 位版本
}

// main.go
package main

import (
    "fmt"
    "image"
    "image/color"
)

func use16BitColor() {
    // 创建 16 位 NRGBA 图片
    img := image.NewNRGBA64(image.Rect(0, 0, 2, 2))
    
    // 设置 16 位颜色值
    color16 := color.NRGBA64{
        R: 32768,  // 50% 红色 (16 位)
        G: 65535,  // 100% 绿色 (16 位)  
        B: 16384,  // 25% 蓝色 (16 位)
        A: 49152,  // 75% 透明度 (16 位)
    }
    
    img.Set(0, 0, color16)
    
    // 读取 16 位颜色
    retrievedColor := img.NRGBA64At(0, 0)
    fmt.Printf("16 位颜色: R=%d, G=%d, B=%d, A=%d\n", 
        retrievedColor.R, retrievedColor.G, retrievedColor.B, retrievedColor.A)
    
    // 转换为 8 位查看
    color8 := color.NRGBAModel.Convert(retrievedColor).(color.NRGBA)
    fmt.Printf("转换为 8 位: R=%d, G=%d, B=%d, A=%d\n", 
        color8.R, color8.G, color8.B, color8.A)
}

为什么要位移色彩数据

为什么前面要实行 16 位的颜色右移转换到 8 位呢?会丢失色彩数据吗?16 位怎么存储的?

图片位数据

需要了解,图片每个像素存储了 RGBA,四个通道数据,分别是 R 的灰度值、G 的灰度值……,灰度可以理解为表示某个单颜色的深浅或者强度。

比如 R 255 表示非常红(100%强度),R0 表示不红(0%强度),再加上别的三原色数据和透明度,来组成更多丰富的色彩程度。

1 位色彩通道

如果没有灰度值,即 R 只能表示一种红色和一种不显示,那么 RGB 排列组合只能表示 8 钟颜色,即 2(R) x 2(G) x 2(B)=8

也就是最基础的 8 色。

  • (0, 0, 0) -> 黑色

  • (1, 0, 0) -> 纯红色

  • (0, 1, 0) -> 纯绿色

  • (0, 0, 1) -> 纯蓝色

  • (1, 1, 0) -> 黄色(红+绿)

  • (1, 0, 1) -> 品红色(红+蓝)

  • (0, 1, 1) -> 青色(绿+蓝)

  • (1, 1, 1) -> 白色

8 位色彩通道

如果加上灰度值,比如 8 位,RGB 排列组合能组成几种颜色?

8 位是指每个通道有 8 位的颜色深度(强度/深浅)。

对于每个通道,8 位能够表示 2^8 = 256 中不同强度(或灰度)级别,强度值从 0(最暗)到 255(最亮)。

RGB 组合起来能够表达的颜色总数是,256(R) x 256(G) x 256(B) = 16,777,216 种颜色,也可以写作 2^24( 3 * 8 bit) 来计算

8 位通道灰度值,表示的颜色总数已经超过人员分辨的极限,所以上文用于处理转换为 8 位颜色已经足够了,无需再存储 16 位。

16 位色彩通道

16 位通道,即通道灰度值达到 16 位,颜色的精细度将大大增加。

简而言之,单个通道灰度值能够表示 2^16 = 65536 种不同的强度级别,比 8 位的 256 钟 要精细很多。

三个通道组合的数量是:65536 (R) x 65536 (G) x 65536 (B) = 2 ^ (16 *3) = 281,474,976,710,656 种颜色

16 位的颜色主要用记录高清相机 RAW 格式、医学影响范畴、高端电影后期调色等。

为什么要位移?会丢失数据吗?

综上已经能够了解清楚,灰度值的位数大小表示的不同程度的单个色彩的强烈程度或者明暗。

  • 无论是 8 位还是 16 位,0 都表示最暗。

  • 位数的最大值表示色彩最强烈(100%),即 8 位 256 和 16 位 65536 表示的是同一个东西。

  • 那么,0 = 0%,256 或 65536 = 100%,中间的所有数据都是表示色彩的某一种程度,相同百分比明暗度的表示的是同一个颜色。

  • 从低 8 位<<8 时,一般不会丢失数据,从高 8 位>>低 8 位一般会损失部分数据,这些损失非常小。

以这个例子理解如何正确位移和保留数据

// 这样设计的数学原理
func whyDuplication() {
    // 目标:保持相同的强度百分比
    
    // 8 位: 128/255 ≈ 50.2%
    original8 := uint8(128)
    percentage8 := float64(original8) / 255.0
    
    // 如果只放在高 8 位: 0x8000 = 32768
    highOnly := uint16(original8) << 8  // 0x8000
    percentageHigh := float64(highOnly) / 65535.0  // ≈ 50.0%
    
    // 如果复制到高低 8 位: 0x8080 = 32896  
    duplicated := uint16(original8)<<8 | uint16(original8)  // 0x8080
    percentageDup := float64(duplicated) / 65535.0  // ≈ 50.2%
    
    fmt.Printf("原始 8 位百分比: %.3f%%\n", percentage8*100)
    fmt.Printf("仅高 8 位百分比: %.3f%%\n", percentageHigh*100)
    fmt.Printf("复制方式百分比: %.3f%%\n", percentageDup*100)
    
    // 复制方式保持了更精确的百分比!
}
  • 右移会损失

  • 左移时可以赋值一份存储到低 8 位,来实现接近原始灰度比例数据。

Comments