# 有向距离场的生成与CG相关应用
**Repository Path**: fish_keqing/generation-and-CG-application-of-sdf
## Basic Information
- **Project Name**: 有向距离场的生成与CG相关应用
- **Description**: 有向距离场的生成与CG相关应用的探索
- **Primary Language**: Go
- **License**: Not specified
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 1
- **Forks**: 0
- **Created**: 2023-11-23
- **Last Updated**: 2025-05-18
## Categories & Tags
**Categories**: Uncategorized
**Tags**: Shader, Go语言
## README
# 有向距离场的生成
## 遍历边界法
**一、遍历边界法**
* 对输入图片进行遮罩处理
* 根据遮罩图寻找边界
* 计算各个像素的有向距离场
* 保存有向距离场
**二、优缺点**
* 优点:可异步并行处理,实现简单,使用GPU并行运算的情况下速度非常快(力大飞砖)
* 缺点:单核情况下速度非常慢,计算时间不稳定(与边界元素个数和计算单元个数有关)
### 生成遮罩
* 输入符号的灰度图,取像素r通道的值
* 使用`weight`控制比较值`compare`的大小,当像素r通道的值小于该值时则认为像素处于符号遮罩里
* 使用`true`和`false`存储遮罩信息
```go
var wg sync.WaitGroup
// GetPhotoMask 获取灰度图遮罩数据
func GetPhotoMask(img image.Image, length, width int, weight float64) (masks [][]bool, err error) {
if weight < 0 || weight > 1 {
return nil, fmt.Errorf("weight must between 0 and 1")
}
masks = make([][]bool, width)
compare := uint32(32767 * weight)
for i := 0; i < width; i++ {
//异步处理
wg.Add(1)
go func(img image.Image, length, lineIdx int, compare uint32) {
defer wg.Done()
maskArray := traversalArray(img, length, lineIdx, compare)
masks[lineIdx] = maskArray
}(img, length, i, compare)
}
wg.Wait()
return
}
// 遍历图片
func traversalArray(img image.Image, length, lineIdx int, compare uint32) []bool {
temp := make([]bool, 0, length)
for j := 0; j < length; j++ {
if r, _, _, _ := img.At(j, lineIdx).RGBA(); r <= compare {
temp = append(temp, false)
} else {
temp = append(temp, true)
}
}
return temp
}
```
### 寻找边界
* 根据遮罩信息判断像素是否在边界,需要从横纵两方向进行遍历
* 情况一:$白\rightarrow 黑$
* 情况二:$黑\rightarrow 白$
* 遍历时需要在二维数组的周围虚拟1个true元素,用于判断数组边缘元素是否属于边界
* 遍历第一个元素时,将`pre`设置为`true`
* 遍历第idx+1个元素时,将`cur`设为true
* **执行两次遍历时会得到重复的元素需要对元素去重**,golang里常用哈希`map`实现
* 坐标过滤公式:$key=x*width+y$(对于不同的坐标其标记是不同的,对于相同坐标拥有相同的标记)
* 为方便后续计算定义结构体存储预计算结果,**计算前需要对`x`和`y`归一化处理(x除以长,y除以宽)**
* `x2y2`:$x^2y^2$
* `2x`:$2x$
* `2y`:$2y$
* 数组索引(i,j)映射到坐标(x,y)的公式:$(i,j)→(x,y)=(j,i)$
```go
type BorderBlock struct {
NormalizeX2y2 float64
Normalize2x float64
Normalize2y float64
}
//构造函数
func createBlock(x, y, length, width int) (block *BorderBlock) {
block = &BorderBlock{}
//归一化
a := float64(x) / float64(length)
b := float64(y) / float64(width)
block.NormalizeX2y2 = a*a + b*b
block.Normalize2x = a + a
block.Normalize2y = b + b
return
}
//寻找边界元素
func FindBorder(masks [][]bool) (border []*BorderBlock) {
width := len(masks)
length := len(masks[0])
borderChan := make(chan *deliver, length)
border = make([]*BorderBlock, 0, length*4)
//边界元素收集者
wg2.Add(1)
go func() {
defer wg2.Done()
collector(borderChan, length, &border)
}()
//异步遍历
for i := 0; i < width; i++ {
wg1.Add(1)
go func(i int) {
defer wg1.Done()
traversalL2R(borderChan, masks, width, length, i)
}(i)
}
//异步遍历
for j := 0; j < length; j++ {
wg1.Add(1)
go func(j int) {
defer wg1.Done()
traversalT2B(borderChan, masks, width, length, j)
}(j)
}
//同步
wg1.Wait()
close(borderChan)
wg2.Wait()
return
}
// 左到右遍历
func traversalL2R(borderChan chan *deliver, photoMask [][]bool, width, length, i int) {
var pre, cur bool
pre = true
for j := 0; j <= width; j++ {
if j == width {
cur = true
} else {
cur = photoMask[i][j]
}
if pre && !cur {
borderChan <- &deliver{key: i*width + j, block: createBlock(j, i, length, width)}
} else if !pre && cur {
borderChan <- &deliver{key: i*width + j, block: createBlock(j-1, i, length, width)}
}
pre = cur
}
}
// 上到下遍历
func traversalT2B(borderChan chan *deliver, photoMask [][]bool, width, length, j int) {
var pre, cur bool
pre = true
for i := 0; i <= width; i++ {
if i == width {
cur = true
} else {
cur = photoMask[i][j]
}
if pre && !cur {
borderChan <- &deliver{key: i*width + j, block: createBlock(j, i, length, width)}
} else if !pre && cur {
borderChan <- &deliver{key: i*width + j, block: createBlock(j, i-1, length, width)}
}
pre = cur
}
}
```
### 计算有向距离场
* 使用坐标的两点间距离公式计算距离,需要对x和y归一化处理
* $distance=\sqrt{(x_1-x)^2+(y_1-y)^2}=x_1^2+y_1^2-2x_1x-2y_1y+x^2+y^2$
* 对于处于遮罩内的像素,距离值需要取反
* 输出时需要将`ndc`空间的值(`[-1,1]`)转为图像空间的值(`[0,1]`),最后输出灰度像素用于存储在图像里
* 数组索引(i,j)映射到坐标(x,y)的公式:$(i,j)→(x,y)=(j,i)$
```go
var wg sync.WaitGroup
// GenerateSDF 获取sdf
func GenerateSDF(width, length int, borders []*border.BorderBlock, masks [][]bool) [][]float64 {
sdf := make([][]float64, len(masks))
for i := 0; i < width; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
arr := make([]float64, length)
for j := 0; j < length; j++ {
arr[j] = computeSDF(float64(j), float64(i), float64(length), float64(width), borders, !masks[i][j])
}
sdf[i] = arr
}(i)
}
wg.Wait()
return sdf
}
// 计算sdf的值
func computeSDF(x, y, length, width float64, borders []*border.BorderBlock, isMask bool) float64 {
a := x / length
b := y / width
a2b2 := a*a + b*b
var _min, temp float64
_min = math.Inf(1)
for _, p := range borders {
distance := p.NormalizeX2y2 + a2b2 - p.Normalize2x*a - p.Normalize2y*b
temp = math.Sqrt(distance)
_min = Min(_min, temp)
}
if isMask {
_min *= -1
}
//归一化距离
return _min / math.Sqrt2
}
//不要使用内置的min,否则不能兼容Go 1.21之前的版本
func Min(a, b float64) float64 {
if a < b {
return a
}
return b
}
```
### 保存有向距离场
* 将有向距离场存储在png图像里
* 数组索引(i,j)映射到坐标(x,y)的公式:$(i,j)→(x,y)=(j,i)$
```go
func SaveInPNG(length, width int, sdf [][]float64, address string) error {
img := image.NewRGBA(image.Rect(0, 0, length, width))
for i := 0; i < width; i++ {
for j := 0; j < length; j++ {
//ndc空间转为像素空间
v := uint16(ndc2pixed(sdf[i][j]) * 65535)
img.Set(j, i, color.RGBA64{R: v, G: v, B: v, A: 65535})
}
}
return save(address, img)
}
func save(address string, img image.Image) error {
if address == "" {
address = "./unknow.png"
}
file, err := os.OpenFile(address, os.O_CREATE|os.O_WRONLY, 0777)
if err != nil {
return err
}
defer file.Close()
b := bufio.NewWriter(file)
err = png.Encode(b, img)
if err != nil {
return err
}
err = b.Flush()
if err != nil {
return err
}
return nil
}
// ndc空间转为像素空间 [-1,1]->[0,1]
func ndc2pixed(num float64) float64 {
return (num + 1) / 2
}
```
## 8SSEDT算法
**一、8SSEDT算法**
* 对输入图片进行遮罩处理,同时初始化矩阵存储距离信息
* 需要2次Pass遍历处理,共遍历4次矩阵
* 第一次Pass(纵轴从上到下遍历)
* 横轴从左到右遍历,此次遍历主要检测像素是否在边界位置,并小范围扩散sdf
* 横轴从右到左遍历,完成较大范围的sdf扩散
* 第二次Pass(纵轴从下到上遍历)
* 横轴从左到右遍历,完成更大范围的sdf扩散
* 横轴从右到左遍历,完成全范围的sdf扩散
* 保存有向距离场
**二、优缺点**
* 优点:速度快,稳定,适用于图形中间件
* 缺点:单核瓶颈(速度快但由于不能异步并行处理不能实时生成有向距离场)
**三、扩散原理**
* 在像素的周围取3×3矩阵,并去除中间元素(8个样本)
* 在8个样本距离场的基础上加上到中间像素的距离,选取最小值并赋值给中间像素
### 矩阵初始化
**一、生成遮罩**
* 方法和遍历边界法的生成遮罩相同
**二、初始化矩阵**
* 每个元素的结构体及其方法
```go
type Point struct {
DX float64 //x偏移
DY float64 //y偏移
}
//返回距离平方,可以输入x和y的偏移量
func (p Point) DistanceSqu(addx, addy float64) float64 {
return (p.DX+addx)*(p.DX+addx) + (p.DY+addy)*(p.DY+addy)
}
```
* 初始化矩阵
```go
var wg2 sync.WaitGroup
//异步处理
func InitMatrix(length, width int) (m [][]*Point) {
m = make([][]*Point, width)
for i := 0; i < width; i++ {
wg2.Add(1)
go func(i int) {
defer wg2.Done()
temp := make([]*Point, 0, length)
for j := 0; j < length; j++ {
temp = append(temp, &Point{})
}
m[i] = temp
}(i)
}
wg2.Wait()
return
}
```
### 计算有向距离场
**一、过滤无效样本**
* 处于边界的元素不能取够8个样本,当取到不存在的样本时会导致程序出现异常,因此要过滤
```go
// 检测元素是否存在
func isEsist(i, j, length, width int) bool {
if i < 0 || j < 0 {
return false
} else if i >= width || j >= length {
return false
}
return true
}
```
**二、检测边界元素的方法**
* 取像素周围的8个样本,检测样本与中间像素的遮罩是否相同,不相同则像素处于边界处
* 若像素处于边界处,基于样本位置设置不同的距离场,其中东西南北优先级比四角高
* 东西:$(0.5,0)$
* 南北:$(0,0.5)$
* 四角:$(0.5,0.5)$
* 设置边界参数x和y都不能同时为0,要求生成的距离场不能存在0值,否则往后的步骤会出现拆东墙补西墙的麻烦
**三、最短距离的计算**
* 取像素周围8个样本,检测样本是否存在,不存在则跳过
* 自定义一个`Min()`函数,要求返回最小值和发生变化的布尔信号
* $(num -1; j-- {
computeShortestDistance(m, masks, i, j, length, width)
}
}
//2nd pass
for j := 0; j < length; j++ {
//top to bottom
for i := 0; i < width; i++ {
computeShortestDistance(m, masks, i, j, length, width)
}
//bottom to top
for i := width - 1; i > -1; i-- {
computeShortestDistance(m, masks, i, j, length, width)
}
}
//归一化并返回数组
normalizeParam := math.Sqrt(math.Pow(float64(length), 2) + math.Pow(float64(width), 2))
for i := 0; i < width; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
temp := make([]float64, length)
for j := 0; j < length; j++ {
//归一化
temp[j] = math.Sqrt(m[i][j].DistanceSqu(0, 0)) / normalizeParam
//设置有向距离
if !masks[i][j] {
temp[j] *= -1
}
}
res[i] = temp
}(i)
}
wg.Wait()
return
}
```
## 算法基准测试
| 算法 | 测试样本 | 核数 | 耗时 |
| ---------- | --------- | ---- | ----------------- |
| 遍历边界法 | S500.png | 2 | 549.678694 ms/op |
| 遍历边界法 | S500.png | 5 | 233.342346 ms/op |
| 8SSEDT算法 | S500.png | 2 | 55.650370 ms/op |
| 8SSEDT算法 | S500.png | 5 | 51.566675 ms/op |
| 遍历边界法 | S1000.png | 2 | 4423.387607 ms/op |
| 遍历边界法 | S1000.png | 5 | 1769.491201 ms/op |
| 8SSEDT算法 | S1000.png | 2 | 267.859178 ms/op |
| 8SSEDT算法 | S1000.png | 5 | 221.036966 ms/op |
# 有向距离场在图形学的应用
## 符号相关
### 重建符号
* 实验内容:生成一张符号的有向距离场,并使用Unity重建符号
```shaderlab
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_Color("_Color",COLOR)=(0,0,0)
}
SubShader
{
Tags { "RenderType"="opaque" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
fixed3 _Color;
fixed4 frag (v2f i) : SV_Target
{
fixed sdf = tex2D(_MainTex, i.uv).r;
if(sdf>0.5){
return fixed4(1,1,1,1);
}
return fixed4(_Color,1);
}
ENDCG
}
}
```
### 镂空符号
* 实验内容:生成一张符号的有向距离场,并使用Unity裁剪掉非符号部分
```shaderlab
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_Color("_Color",COLOR)=(0,0,0)
_alpha("_alpha",Range(0,1))=0.5
}
SubShader
{
Tags {"Queue"="AlphaTest" "IgnoreProjector"="True" "RenderType"="TransparentCutout"}
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
float _alpha;
fixed3 _Color;
fixed4 frag (v2f i) : SV_Target
{
fixed sdf = tex2D(_MainTex, i.uv).r;
//裁剪剔除
clip(_alpha-sdf);
return fixed4(_Color,1);
}
ENDCG
}
}
```
### 符号平滑切换
**一、数据存储**
* 假设有两个符号,获取符号的有向距离场
* 两符号的重叠区域设置为1,1默认为与所有符号重叠的参数
* 不与两符号重叠的区域设置为0,0默认为不与所有符号重叠的参数
* 将符号A的数据存储在$[0.1,0.5]$
* 将符号B的数据存储在$[0.5,0.9]$
* 产生bias的原因:shader使用$[0,1]$的范围表示一个像素的范围,而图像单通道的范围为$[0,255]$,缩放成[0,1]时会存在微小偏移
**二、滑动窗口**
* 假设有一个0.5长度的滑动窗口,在该窗口范围内的参数可以允许输出像素,窗口最大参数$\alpha$
* 当参数$\alpha$从0到0.5滑动时永远是参数小的部分先出现,参数大的部分后出现
* 当参数$\alpha$从0.5到1滑动时永远是参数小的部分先消失,参数大的部分后消失
**三、混合原理**
* 计算两个符号的有向距离场(A,B)
* 对于重叠区域和不被所有符号重叠区域不参与混合处理(只有端点值才能使用`=`)
* 重叠区域设置参数1
* 不与所有符号重叠的区域设置参数0
* 被符号A覆盖但不被符号B覆盖的区域
* 数据存储在$[0.1,0.5]$
* 混合公式:(根据效果只选一种公式)
$$
blend =0.5-\frac{|A|}{|A|+|B|}*0.4
$$
* 被符号B覆盖但不被符号A覆盖的区域
* 数据存储在$[0.5,0.9]$
* 混合公式:
$$
blend =0.9-\frac{|B|}{|A|+|B|}*0.4
$$
```go
func BlendChar(charA [][]float64, charB [][]float64, length, width int) (res [][]float64) {
res = make([][]float64, width)
for i := 0; i < width; i++ {
temp := make([]float64, length)
for j := 0; j < length; j++ {
//c'die区域
if isChar(charA[i][j]) && isChar(charB[i][j]) {
temp[j] = 1
//非符合区域
} else if !isChar(charA[i][j]) && !isChar(charB[i][j]) {
temp[j] = 0
} else {
total := math.Abs(charB[i][j]) + math.Abs(charA[i][j])
//只在B [0.5,0.9]
if isChar(charB[i][j]) {
temp[j] = 0.9 - math.Abs(charB[i][j])/total*0.4
} else {
//只在A [0.1,0.5]
temp[j] = 0.5 - math.Abs(charA[i][j])/total*0.4
}
}
}
res[i] = temp
}
return
}
func isChar(n float64) bool {
return n <= 0
}
```
**四、符号切换Shader实现**
* 初始化时将滑动窗口放在A上,使用`_Min`控制滑动窗口
* 裁剪顺序
* 裁剪参数为0的像素(剔除不被符号覆盖的区域)
* 将参数1改为0.5(还原重叠的区域)
* 剔除低于滑动窗口下界的区域
* 剔除高于滑动窗口上界的区域
* `bias`处理:
* 上界剔除处加长$0.01$
* 下界没多大影响不建议修改frag或调整_Min的值$(+0.01)$,否则拆东墙补西墙
```shaderlab
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_Min("_Min",Range(0.1,0.5))=0.1
}
SubShader
{
Tags { "Queue"="AlphaTest" "IgnoreProjector"="True" "RenderType"="TransparentCutout" }
Pass
{
CGPROGRAM
......
float _Min;
fixed4 frag (v2f i) : SV_Target
{
fixed param = tex2D(_MainTex, i.uv).r;
//裁剪参数为0的像素
clip(param-0.01);
//重合位置居中
param=param==1?0.5:param;
//滑动窗口裁剪
//下界
clip(param-_Min);
//上界,需要加bias保证符号完整输出
clip(0.4-param+_Min+0.01);
return fixed4(0,1,0,1);
}
ENDCG
}
}
```
## 卡通风格脸部阴影
### 材料准备
* $[0,180°]$区间内不同角度下的`FaceShadow`(一般由艺术家根据某种效果画出来)
* 用于渲染时白色表示阴影面,黑色表示受光面
### facemap生成原理
* 准备若干张`FaceShadow`,并按夹角从小到大排列(白色表示阴影面,黑色表示受光面)
* 取出前两张`FaceShadow`,将两张`FaceShadow`进行简单的混合平均,发现得出的图片有一块区域是灰色的,而该灰色的区域是第一张`FaceShadow`过渡到第二张`FaceShadow`需要变化的区域。依此类推可以得到任意一张`FaceShadow`过渡到下一张`FaceShadow`需要变化的区域。当有n张`FaceShadow`时,这种过渡区域有$(n-1)$个
* 为了得到一张从0到1过渡的`facemap`,且特定的夹角要有对应的效果,则需要将$[0,1]$平均分摊到$(n-1)$个过渡区域里,即第$i$个过渡区域的取值范围为$[\frac{N-i-1}{N},\frac{N-i}{N}),N=n-1$,通过该方式确定了在某一过渡区域里的像素值的下限和上限(其中下限的值可取,上限逼近的值)
* 在过渡区域里,为了得到平滑且有意义的过渡值(过渡区域像素越靠近白色区域值越大,越靠近黑色区域值越小),则使用有向距离场插值实现,将所有`FaceShadow`转化为sdf图
* 有向距离场插值,假设对过渡区域里某个点进行插值
* 前一张`FaceShadow`的sdf图该点的值$sdf_{pre}$(该点在`FaceShadow`的黑色区域内,所以为负值,表示点到过渡区域的右边界最近的距离)
* 后一张`FaceShadow`的sdf图该点的值$sdf_{cur}$(该点在`FaceShadow`的白色区域内,所以为正值,表示点到过渡区域的左边界最近的距离)
* 过渡区域值的下限:$\frac{n-i-2}{n-1}$($n$为`FaceShadow`的数量,$n-1$为过渡区域的数量)
$$
Grayscale=\frac{n-i-2}{n-1}+\frac{abs(sdf_{cur})}{abs(sdf_{pre})+sdf_{cur}}×\frac{1}{n-1}
$$
* 最终值以灰度的形式保存在`facemap`里,基本还原`reference`
### 逻辑实现
**一、生成有向距离场**
* `facemap`通常预计算生成,使用遍历边界法生成高质量有向距离场
* 有向距离场使用二维的双浮点数暂存(`[][]float64`)
* 不要将有向距离场存储在图像里然后再提取使用,否则会有明显的层次感且过渡不平滑(精度问题)
**二、混合**
* 获取一张背景纯白色的图
* 依据`facemap`生成原理实现逻辑,混合结果使用灰度保存
* 可以存储在`alpha`通道,但会因为透明通道预处理会导致`RGB`通道发生改变(使用PS把颜色填回去)
```go
//main
for i := 1; i <= 9; i++ {
BlendSDF(sdfs[i-1], sdfs[i], length, width, i, len(sdfs), resultImg)
}
```
```go
//grey
func BlendSDF(pre [][]float64, cur [][]float64, length, width, idx, photoNum int, outputImg *image.RGBA) {
for i := 0; i < width; i++ {
for j := 0; j < length; j++ {
min := 1 - float64(idx)/float64(photoNum-1)
preValue := pre[i][j]
curValue := cur[i][j]
if preValue*curValue <= 0 {
temp := curValue/(math.Abs(preValue)+curValue)/float64(photoNum-1) + min
c := uint16(temp * 65535)
outputImg.SetRGBA64(j, i, color.RGBA64{R: c, G: c, B: c, A: 65535})
} else if curValue < 0 && preValue < 0 {
outputImg.SetRGBA64(j, i, color.RGBA64{R: 0, G: 0, B: 0, A: 65535})
}
}
}
}
```
```go
func BlendSDFToAlpha(pre [][]float64, cur [][]float64, length, width, idx, photoNum int, outputImg *image.RGBA) {
for i := 0; i < width; i++ {
for j := 0; j < length; j++ {
_min := 1 - float64(idx)/float64(photoNum-1)
preValue := pre[i][j]
curValue := cur[i][j]
if preValue*curValue <= 0 {
alpha := curValue/(math.Abs(preValue)+curValue)/float64(photoNum-1) + _min
outputImg.SetRGBA64(j, i, *GetColor(&color.NRGBA64{R: 65535, G: 65535, B: 65535, A: uint16(alpha * 65535)}, outputImg))
} else if curValue < 0 && preValue < 0 {
outputImg.SetRGBA64(j, i, *GetColor(&color.NRGBA64{R: 65535, G: 65535, B: 65535, A: 0}, outputImg))
}
}
}
}
//非预乘像素返回RGBA
func GetColor(c *color.NRGBA64, img *image.RGBA) *color.RGBA64 {
r, g, b, a := img.ColorModel().Convert(c).RGBA()
return &color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)}
}
```
**三、保存结果**
* 保存为`.png`格式
```go
func SaveInPng(address string, img image.Image) error {
if address == "" {
address = "./unknow.png"
}
file, err := os.OpenFile(address, os.O_CREATE|os.O_WRONLY, 0777)
if err != nil {
return err
}
defer file.Close()
b := bufio.NewWriter(file)
err = png.Encode(b, img)
if err != nil {
return err
}
err = b.Flush()
if err != nil {
return err
}
return nil
}
```