基于 Go 协程实现图片马赛克应用(上):同步版本


注:本教程代码整理自《Go Web Programming》一书并发编程部分。

介绍完 Go 并发编程基础和常见并发模式的实现后,我们来看一个完整的项目 —— 基于 Go 语言实现照片马赛克功能,为了对比并发与非并发的性能差异,我们先通过同步代码实现这个功能。

整体方案

开始之前,我们先设计下照片马赛克功能的整体实现方案:

  1. 构建嵌入图片数据库:通过遍历一个图片目录,然后使用文件名作为 key,图片平均颜色作为 value 生成哈希表保存在内存中(平均颜色是通过计算每个像素的 RGB 值然后将它们加起来,再除以总像素数得到);
  2. 将目标图片克隆一份用于绘制马赛克图片;
  3. 将目标图片按照指定尺寸(用户上传)平均切分为多个小区块;
  4. 循环遍历每个小区块,对于每个区块,假设平均颜色是当前区块左上角像素的 RGB 颜色值(三元数组);
  5. 在嵌入图片数据库中根据与当前区块平均颜色最接近为条件查找相应的嵌入图片,然后将其调整为当前区块大小并绘制到马赛克图片的当前区块位置,最接近图片的查找方法是计算两个平均颜色的欧几里得距离(将三元组的平均颜色值转化为距离值);
  6. 从嵌入图片数据库中移除该嵌入图片,从而保证马赛克图片中的嵌入图片都是唯一的(这里的数据库是从初始化数据库克隆出来的,删除操作只影响当前图片马赛克生成,不影响其他图片马赛克处理)。

然后根据上述技术方案编写代码。

前期准备

在 GoLand 中基于 Go modules 新建一个 mosaic 项目:

-w994

在新项目中创建一个 sync 目录存放同步代码。

计算平均颜色

sync 目录下新建一个 tilesdb.go 文件存放嵌入图片数据库操作相关代码。

首先编写计算平均颜色的函数 averageColor 代码如下:

package sync

import "image"

func averageColor(img image.Image) [3]float64 {
    bounds := img.Bounds()
    r, g, b := 0.0, 0.0, 0.0
    for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
        for x := bounds.Min.X; x < bounds.Max.X; x++ {
            r1, g1, b1, _ := img.At(x, y).RGBA()
            r, g, b = r+float64(r1), g+float64(g1), b+float64(b1)
        }
    }
    totalPixels := float64(bounds.Max.X * bounds.Max.Y)
    return [3]float64{r / totalPixels, g / totalPixels, b / totalPixels}
}

计算方法是遍历图片每个像素的 RGB 值,将它们分别累加起来,再分别除以总像素数得到三元组的平均颜色值(即包含 R、G、B 三个颜色平均值的数组)。

将目标图片调整为指定尺寸

接下来,在 tilesdb.go 中继续编写将目标上传图片调整为指定尺寸的实现函数 resize

import "image/color"

func resize(in image.Image, newWidth int) image.NRGBA {
    bounds := in.Bounds()
    width := bounds.Max.X - bounds.Min.X
    ratio := width / newWidth
    out := image.NewNRGBA(image.Rect(bounds.Min.X/ratio, bounds.Min.X/ratio, bounds.Max.X/ratio, bounds.Max.Y/ratio))
    for y, j := bounds.Min.Y, bounds.Min.Y; y < bounds.Max.Y; y, j = y+ratio, j+1 {
        for x, i := bounds.Min.X, bounds.Min.X; x < bounds.Max.X; x, i = x+ratio, i+1 {
            r, g, b, a := in.At(x, y).RGBA()
            out.SetNRGBA(i, j, color.NRGBA{uint8(r), uint8(g), uint8(b), uint8(a)})
        }
    }
    return *out
}

这是一个图片按比例缩放操作。

构建嵌入图片数据库

接下来,在 tilesdb.go 中编写 TilesDB 函数遍历指定目录构建嵌入图片数据库:

import (
    "fmt"
    "io/ioutil"
    "os"
)

func TilesDB() map[string][3]float64 {
    fmt.Println("开始构建嵌入图片数据库...")
    db := make(map[string][3]float64)
    files, _ := ioutil.ReadDir("tiles")
    for _, f := range files {
        name := "tiles/" + f.Name()
        file, err := os.Open(name)
        if err == nil {
            img, _, err := image.Decode(file)
            if err == nil {
                db[name] = averageColor(img)
            } else {
                fmt.Println("构建嵌入图片数据库出错:", err, name)
            }
        } else {
            fmt.Println("构建嵌入图片数据库出错:", err, "无法打开文件", name)
        }
        file.Close()
    }
    fmt.Println("完成嵌入图片数据库构建")
    return db
}

这个数据库会从项目根目录下的 tiles 目录中遍历图片,并将其转化为存储在内存中的字典数据结构(map[string][3]float64),key 是图片文件名(字符串类型),value 是该图片的平均颜色值(浮点型三元数组)。

从嵌入图片数据库查找嵌入图片

我们可以在这个数据库中查询与目标图片切分出的小图片区块平均颜色最接近的嵌入图片,对应的查询函数实现代码如下:

func nearest(target [3]float64, db *map[string][3]float64) string {
    var filename string
    smallest := 1000000.0
    for k, v := range *db {
        dist := distance(target, v)
        if dist < smallest {
            filename, smallest = k, dist
        }
    }
    delete(*db, filename)
    return filename
}

这个函数传入的第一个参数是目标图片切分出来的小图片区块的平均颜色,第二个参数是嵌入图片数据库指针,在函数体中我们通过遍历嵌入图片数据库查找与目标图片区块平均颜色最接近的嵌入图片,找到之后将其从嵌入图片数据库中删除。

计算最接近的平均颜色

这里还调用 distance 方法计算两个平均颜色的距离,然后通过距离值来判定是否最接近:

import "math"

func distance(p1 [3]float64, p2 [3]float64) float64 {
    return math.Sqrt(sq(p2[0]-p1[0]) + sq(p2[1]-p1[1]) + sq(p2[2]-p1[2]))
}

func sq(n float64) float64 {
    return n * n
}

sq 函数用于计算指定值的平方。

克隆嵌入图片数据库

每次创建马赛克图片都要遍历并加载嵌入图片数据库的话非常笨重,所以我们编写了一个 cloneTilesDB 数据库来克隆这个数据库,这样遍历和加载操作只需要执行一次就好了:

var TILESDB map[string][3]float64
    
func cloneTilesDB() map[string][3]float64 {
    db := make(map[string][3]float64)
    for k, v := range TILESDB {
        db[k] = v
    }
    return db
}

准备好以上嵌入图片数据库相关操作函数之后(所有代码存放在 sync/tilesdb.go 文件),接下来,我们正式来编写实现图片马赛克功能的 Web 应用。

Web 服务实现

编写处理器代码

sync 目录下新建一个 handler.go 文件,编写用户上传图片马赛克处理代码如下:

package sync

import (
    "bytes"
    "encoding/base64"
    "fmt"
    "html/template"
    "image"
    "image/draw"
    "image/jpeg"
    "net/http"
    "os"
    "strconv"
    "time"
)

func Mosaic(w http.ResponseWriter, r *http.Request) {
    t0 := time.Now()
    // 从 POST 表单获取用户上传内容
    r.ParseMultipartForm(10485760) // 最大上传内容大小是 10 MB
    // 通过 image 字段读取用户上传图片
    file, _, _ := r.FormFile("image")
    defer file.Close()
    // 通过 tile_size 字段读取用户设置的区块尺寸
    tileSize, _ := strconv.Atoi(r.FormValue("tile_size"))
    // 解码获取原始图片
    original, _, _ := image.Decode(file)
    bounds := original.Bounds()
    // 克隆目标图片用于绘制马赛克图片
    newimage := image.NewNRGBA(image.Rect(bounds.Min.X, bounds.Min.X, bounds.Max.X, bounds.Max.Y))
    // 克隆嵌入图片数据库
    db := cloneTilesDB()
    // 从 (0, 0) 坐标开始将目标图片按照 tile_size 值均分成多个小区块
    sp := image.Point{0, 0}
    for y := bounds.Min.Y; y < bounds.Max.Y; y = y + tileSize {
        for x := bounds.Min.X; x < bounds.Max.X; x = x + tileSize {
            // 使用图片区块左上角像素颜色作为该区块的平均颜色
            r, g, b, _ := original.At(x, y).RGBA()
            color := [3]float64{float64(r), float64(g), float64(b)}
            // 从嵌入图片数据库获取平均颜色与之最接近的嵌入图片
            nearest := nearest(color, &db)
            file, err := os.Open(nearest)
            if err == nil {
                img, _, err := image.Decode(file)
                if err == nil {
                    // 将嵌入图片调整到当前区块大小
                    t := resize(img, tileSize)
                    tile := t.SubImage(t.Bounds())
                    tileBounds := image.Rect(x, y, x+tileSize, y+tileSize)
                    // 然后将调整大小后的嵌入图片绘制到当前区块位置,从而真正嵌入到马赛克图片中
                    draw.Draw(newimage, tileBounds, tile, sp, draw.Src)
                } else {
                    fmt.Println("error:", err, nearest)
                }
            } else {
                fmt.Println("error:", nearest)
            }
            file.Close()
        }
    }

    buf1 := new(bytes.Buffer)
    jpeg.Encode(buf1, original, nil)
    // 原始图片 base64 值
    originalStr := base64.StdEncoding.EncodeToString(buf1.Bytes())

    buf2 := new(bytes.Buffer)
    jpeg.Encode(buf2, newimage, nil)
    // 马赛克图片 base64 值
    mosaic := base64.StdEncoding.EncodeToString(buf2.Bytes())

    t1 := time.Now()
    // 构建一个包含原始图片、马赛克图片和处理时间的字典类型变量 images
    images := map[string]string{
        "original": originalStr,
        "mosaic":   mosaic,
        "duration": fmt.Sprintf("%v ", t1.Sub(t0)),
    }
    // 将 images 值渲染到响应 HTML 文件返回给用户
    t, _ := template.ParseFiles("views/results.html")
    t.Execute(w, images)
}

结合最开始设计的技术方案和代码注释,理解上述代码应该没什么问题。当然,还需要一个渲染上传图片表单视图模板的处理器:

func Upload(w http.ResponseWriter, r *http.Request) {
    t, _ := template.ParseFiles("views/upload.html")
    t.Execute(w, nil)
}

编写项目入口函数

mosaic 根目录下新建一个 main.go,编写项目入口函数代码如下:

package main

import (
    "fmt"
    "mosaic/sync"
    "net/http"
)

func main() {
    mux := http.NewServeMux()
    files := http.FileServer(http.Dir("public"))
    mux.Handle("/static/", http.StripPrefix("/static/", files))
    mux.HandleFunc("/", sync.Upload)
    mux.HandleFunc("/mosaic", sync.Mosaic)
    server := &http.Server{
        Addr:    "127.0.0.1:8080",
        Handler: mux,
    }
    // 初始化嵌入图片数据库(以便在处理图片马赛克时克隆)
    sync.TILESDB = sync.TilesDB()
    fmt.Println("图片马赛克应用服务器已启动")
    server.ListenAndServe()
}

在这里,我们基于 Go 内置的 net/http 包设置了 Web 服务器启动参数和路由设置,并且初始化了全局可用的嵌入图片数据库。

编写视图模板代码

接下来,为了让应用可以正常访问,我们还需要准备项目所需的前端资源文件。

上传图片页面

首页是上传图片页面视图模板,我们在项目根目录下新建 views 子目录存放视图模板,然后在该目录下新建 upload.html,并编写视图模板代码如下:

<!DOCTYPE html>
<html>
<head>
	<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
	<title>图片马赛克</title>
	<meta content='width=device-width, initial-scale=1.0, maximum-scale=1.0' name='viewport'>
	<meta content='none' name='robots'>
	<link href='/static/css/bootstrap.min.css' rel='stylesheet' type='text/css'>
	<link href='/static/css/font-awesome.min.css' rel='stylesheet' type='text/css'>
	<script src='/static/js/jquery.min-1.9.1.js' type='text/javascript'></script>
	<script src='/static/js/bootstrap.min.js' type='text/javascript'></script>
	<style>
        body {
            padding-top: 20px;
        }

        p {
            font-size: 16px;
            line-height: 140%;
        }

        .content {
            background-color: #fff;
            padding: 20px 50px 50px 50px;
        }
	</style>
</head>
<body>
<div class='container'>
	<div class='content'>
		<h1>图片马赛克</h1>
		<div class="lead">根据预选目录下的图片为上传图片创建马赛克版本:</div>


		<form class="form form-inline" method="post" action="/mosaic" enctype="multipart/form-data">
			<select name="tile_size" class="form-control input-lg">
				<option value="10">10</option>
				<option value="15" selected>15</option>
				<option value="20">20</option>
				<option value="25">25</option>
				<option value="50">50</option>
				<option value="100">100</option>
			</select>

			<input type="file" name="image" class="form-control input-lg"/>
			<button class="btn btn-primary btn-lg" type="submit">
				开始创建
			</button>
			<p class="help-block">选择像素级别将上传的 JPEG 图片转化为对应的马赛克图片。</p>
		</form>
	</div>
</div>
</body>
</html>

我们需要上传两个值,一个是马赛克嵌入区块尺寸大小(像素级别),一个是目标上传图片,目前仅支持 JPEG 格式。

马赛克处理结果页面

马赛克图片生成后,会将原始图片和马赛克图片编码为 base64 字符串,并渲染到 views/results.html 视图模板中:

<!DOCTYPE html>
<html>
<head>
	<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
	<title>图片马赛克</title>
	<meta content='width=device-width, initial-scale=1.0, maximum-scale=1.0' name='viewport'>
	<meta content='none' name='robots'>
	<link href='/static/css/bootstrap.min.css' rel='stylesheet' type='text/css'>
	<link href='/static/css/font-awesome.min.css' rel='stylesheet' type='text/css'>
	<script src='/static/js/jquery.min-1.9.1.js' type='text/javascript'></script>
	<script src='/static/js/bootstrap.min.js' type='text/javascript'></script>
	<!--[if lt IE 9]>
	<script src='/static/js/html5.js'></script>
	<![endif]-->
	<style>
        body {
            padding-top: 20px;
        }

        p {
            font-size: 16px;
            line-height: 140%;
        }

        .content {
            background-color: #fff;
            padding: 20px 50px 50px 50px;
        }
	</style>
</head>
<body>
<div class='container'>
	<div class="col-md-6">
		<img src="data:image/jpg;base64,{{ .original }}" width="100%">
		<div class="lead">原始图片</div>
	</div>
	<div class="col-md-6">
		<img src="data:image/jpg;base64,{{ .mosaic }}" width="100%">
		<div class="lead">马赛克结果 - {{ .duration }}</div>
	</div>
	<div class="col-md-12 center">
		<a class="btn btn-lg btn-info" href="/">返回上传页面</a>
	</div>
</div>
<br>
</body>
</html>    

静态资源文件

最后,我们需要在项目根目录下的 public 目录中准备好项目所需的静态资源文件(CSS、JS、字体文件,你也可以通过 CDN 引入这些文件):

-w707

注:相关文件可以从 Github 代码仓库拉取。

以及在 tiles 目录下准备好用于构建嵌入图片数据库的图片(你可以将本地任意图片目录下 的图片拷贝过来):

-w675

演示图片马赛克功能

做好上述所有准备工作后,我们就可以在项目根目录下启动应用进行测试了:

-w650

在浏览器访问 http://127.0.0.1:8080,即可看到图片上传界面:

-w785

上传一张图片,最终马赛克效果图如下,处理时间是 4.7s:

-w1200

这个马赛克的效果与图片库的选取有关,相关度越高,还原性越好。下篇教程,我们将演示基于协程对本项目进行并发重构,然后再看看马赛克效果和耗时情况。


点赞 取消点赞 收藏 取消收藏

<< 上一篇: 常见的并发模式实现(三):通过无缓冲通道创建协程池

>> 下一篇: 基于 Go 协程实现图片马赛克应用(下):并发重构