Goのnet/httpパッケージで画像のアップロードと加工を行ってみる
こんにちは、株式会社SHIFT、自動化エンジニアの水谷です。
前回はOpenCVのGoラッパー"GoCV"で写真を読み込んでクロスプロセスっぽい加工を行うアプリケーションを作成しました。なんとかそれっぽい雰囲気を持つ写真が作成できたはいいものの、そのアプリでは画面の表示にはまったく凝らず、OpenCVが作成するシンプルなウィンドウの中で写真を表示させる素っ気ないものでした。また、パラメーターはコマンドラインで渡すようにしていたため、使い勝手もあまりよくありません。
そこで、今回はGoが標準で持っているhttpクライアント/サーバーパッケージであるnet/httpを使って、前回のアプリをWebアプリ化して、少し使いやすくしてみたいと思います。
Goに標準搭載されているnet/httpパッケージとは
Goは2009年に最初のバージョンが公開された新しいプログラム言語ということもあり、httpクライアントおよびhttpサーバー機能を"標準"パッケージとして持っています。つまり、これを使えばWebサーバーを用意する必要もなく、それどころか外部パッケージの追加すらせず、Webアプリを作成することができるのです。
net/httpのhttpサーバー側の機能を、標準的な設定で使用するのはとても簡単で、下のような短いコードで「Hello World」的なWebアプリが出来上がります。
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "Hello from net/http server")
})
http.ListenAndServe(":80", nil)
}
実行するには、まず空のディレクトリを作成し、その中にこのコードを"main.go"という名で保存します。そして、"go run main.go"でビルド&実行します。この状態でブラウザから http://localhost にアクセスすると、"Hello from net/http server"と表示されます。
この例では、トップページ("/")にアクセスした際の応答は、無名関数で実装していますが、もちろん名前付きの関数として実装できます。このように、各ページに対する応答をHandleFuncで記述していくことで、Webサイトを作っていくことができるのです。
今回のアプリでは、Webブラウザから写真をアップロードし、それを指定したパラメーターでクロスプロセス風加工を行い、両者を並べて表示するようなWebアプリにしたいので、画像とフォームを表示するトップページ("/")、元画像のアップロード処理を行うページ("/upload")、クロスプロセス現像を行うページ("/crossprocess")の3つを実装します。
少し補足しますと、加工元画像も加工後の画像もトップページだけに表示し、/uploadで画像の保存を行ったり、/crossprocessでクロスプロセス画像を作成した後はトップページにリダイレクトすることになります。
画像ファイルのアップロードと表示
まずは、画像ファイルのアップロード部分から作っていきましょう。トップページのindex.htmlには、下のようなformを用意しておきます。
<form action="/upload" enctype="multipart/form-data" method="post">
<input type="file" name="upload" id="upload">
<input type="submit" value="Upload" />
</form>
これで下のような「ファイル選択」ボタンと「Upload」ボタンが追加されます。
ユーザーが「ファイル選択」ボタンを押して、ダイアログボックスからファイルを指定すると、「選択されていません」と表示されている場所にファイル名が表示されます。そして「Upload」ボタンを押すと画像ファイルの内容はmultipartのform-dataとして、"action"で指定している/uploadにPOSTされます。
"UploadHandler"という関数を用意し、下のようにhttp.HandleFuncで登録します。
http.HandleFunc("/upload", UploadHandler)
そして、UploadHanderでは、画像データをデコードしてファイルとして保存するのですが、まずは下のコードでmultipartのデータをパースします。
err := r.ParseMultipartForm(32 << 20)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
続いて、パースした画像データをファイルとして扱えるようhttp.Request.FormFileをコールします。ここで1行目のrがハンドラーの引数として与えられたhttp.Requestです。
fileSrc, _, err := r.FormFile("upload")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer fileSrc.Close()
あとは、コピー先のファイルを作成して、io.Copyメソッドを実行します。
fileDest, err := os.Create("/tmp/original.jpg")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer fileDest.Close()
io.Copy(fileDest, fileSrc)
これでファイルのアップロードが完了しましたので、http.Redirectでトップページにリダイレクトします。
クロスプロセス加工
次にクロスプロセス加工を行う部分を作りましょう。こちらも、トップページに下のようなフォームを用意しておき、「CrossProcess」ボタンを押した際に各パラメーターが/crossprocessにPOSTされるようにします。
なお、ここでユーザーは色相の移動角度、RGBそれぞれの倍率、それから周辺光量落ちの度合い(low/high/noneからの選択)です。
/crossprocessのハンドラー(CrossProcessHandler)を下のように追加します。
http.HandleFunc("/crossprocess", CrossProcessHandler)
CrossProcessHandlerでは、各パラメーターの値を下のようにして取り出します(rはハンドラーの引数として与えられたhttp.Request、エラー処理は省略)。
hShift, _ := strconv.ParseUint(r.FormValue("hueshift"), 10, 8)
あとは、前回の記事のコードをそのまま使って、クロスプロセス(風)の画像処理を行い、出来上がった画像を/tmp/crossprocess.jpgとして保存します。
gocv.IMWrite("/tmp/crossprocess.jpg", imgCrossProcess)
text/templateを使ったindex.htmlの生成
さてさて、あとはトップページで画像を表示する部分を作ればよいのですが、Goにはtext/templateというパッケージがあるようなので、これを使って実装してみたいと思います。
このパッケージを使うと、テンプレートとして用意しておいたファイルに対して変数の置換や条件分岐を含む各種操作を行ったうえで、writerオブジェクト(今回の場合はhttp.ResponseWriter)に流し込んでくれます。
templates := template.Must(template.ParseFiles("templates/index.html"))
if err := templates.ExecuteTemplate(w, "index.html", data); err != nil {
log.Fatal(err)
}
ここで、テンプレート内で使用するパラメーターは変数dataにmap形式で用意しておくことになります。
画像表示の部分についても、(URLを埋め込むのではなく)ファイルの内容をBASE64にして直接埋め込んでしまうことにします(これがベストな方法かどうかはわかりませんが……)。その際のデータ本体が .Imageという変数(テンプレート内の変数)として、下のように記述します。
<img src="data:image/jpg;base64,{{ .Image }}" >
そして、トップページのハンドラーでは、下のようにファイルをBASE64化しています。
var imgStr [2]string
for i, imageFile := range [](string){"/tmp/original.jpg", "/tmp/crossprocess.jpg"} {
buffer, err := ioutil.ReadFile(imageFile)
if err == nil {
imgStr[i] = base64.StdEncoding.EncodeToString(buffer)
}
}
そして、BASE64のデータや、他のデータをまとめてmapにしたdata変数を使って、テンプレート処理を行っています。
data := map[string]interface{}{ "Image": imgStr[0],
"ImageCrossProcess": imgStr[1],
"HueShift": hueShift,
"BRate": bRate,
"GRate": gRate,
"RRate": rRate,
"Vignette": vignette }
if err := templates.ExecuteTemplate(w, "index.html", data); err != nil {
log.Fatalln("Unable to execute template.")
}
テンプレートファイルは下のようになります(ファイル名は templates/index.html)。後半に出てくるHueShiftやBRateなどは、if文で、その値が定義されていれば、その値を表示し、定義されていなければデフォルト値が表示されるようにしています。
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Cross Process</title>
</head>
<body>
<table>
<tr>
<th>original image</th><th>cross process image</th>
</tr>
<tr>
<th>
<img width="640" align="center" valign="center" src="data:image/jpg;base64,{{ .Image }}" alt="Upload Image (.jpg)" />
</th>
<th>
<img width="640" align="center" valign="center" src="data:image/jpg;base64,{{ .ImageCrossProcess }}" alt="Cross process image will be shown here" />
</th>
</tr>
</table>
<form action="/upload" enctype="multipart/form-data" method="post">
<input type="file" name="upload" id="upload" multiple="multiple">
<input type="submit" value="Upload" />
</form>
<br>
<form action="/crossprocess" method="post">
<table>
<tr>
<td>Hue Shift:</td>
<td><input type="text" value="{{ if .HueShift }}{{ .HueShift }}{{ else }}30{{ end }}" name="hueshift" /></td>
</tr>
<tr>
<td>Blue :</td>
<td><input type="text" value="{{ if .BRate }}{{ .BRate }}{{ else }}0.5{{ end }}" name="brate" /></td>
</tr>
<tr>
<td>Red :</td>
<td><input type="text" value="{{ if .RRate }}{{ .RRate }}{{ else }}0.9{{ end }}" name="rrate" /></td>
</tr>
<tr>
<td>Green:</td><td><input type="text" value="{{ if .GRate }}{{ .GRate }}{{ else }}1.0{{ end }}" name="grate" /></td></tr>
<tr>
<td>Vignette</td>
<td>
<select name="vignette">
<option value="low" {{ if eq .Vignette "low" }}selected{{ end }}>low</option>
<option value="high" {{ if eq .Vignette "high" }}selected{{ end }}>high</option>
<option value="none" {{ if eq .Vignette "none" }}selected{{ end }}>none</option>
</select>
</td>
</tr>
</table>
<input type="submit" value="CrossProcess" />
</form>
</body>
</html>
これで一通り完成です。"go run main.go"を実行すると、下のようにアプリが動きました。
――――――――――――――――――――――――――――――――――
これで、前回のアプリに比べればパラメーターを変化させながら、加工後の画像を元画像と比較して見ることができるようになったため、少しは使いやすくなったかと思います。
ただ、このままでは複数のユーザーが使うことができませんし、せめてファイルの保存ボタンくらいは用意したいところではあります(現状では、画像を保存したい場合は、画像を右クリックして「名前を付けて画像を保存」などを選ぶ必要がある)。
どうせ作るなら、フロントエンドはTypeScript+Reactなどできれいに作って、Goはバックエンド処理だけを行うようにするのがよいかな、とも思いますが、さくっと1ファイル(+テンプレート)で作ってしまおう、という場合には、こんなアプローチもありかなと思います。
最後にGoのコード全体を載せておきます。
package main
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/jpeg"
"io"
"log"
"math"
"net/http"
"os"
"strconv"
"text/template"
"gocv.io/x/gocv"
)
func IndexHandler(w http.ResponseWriter, r *http.Request) {
hueShift := r.FormValue("hueshift")
bRate := r.FormValue("brate")
gRate := r.FormValue("grate")
rRate := r.FormValue("rrate")
vignette := r.FormValue("vignette")
var imgStr [2]string
for i, imageFile := range [](string){"/tmp/original.jpg", "/tmp/crossprocess.jpg"} {
file, err := os.Open(imageFile)
defer file.Close()
if err == nil {
img, _, err := image.Decode(file)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
buffer := new(bytes.Buffer)
if err := jpeg.Encode(buffer, img, nil); err != nil {
log.Fatalln("Unable to encode image.")
}
imgStr[i] = base64.StdEncoding.EncodeToString(buffer.Bytes())
} else {
imgStr[i] = ""
}
}
templates := template.Must(template.ParseFiles("templates/index.html"))
data := map[string]interface{}{ "Image": imgStr[0],
"ImageCrossProcess": imgStr[1],
"HueShift": hueShift,
"BRate": bRate,
"GRate": gRate,
"RRate": rRate,
"Vignette": vignette }
if err := templates.ExecuteTemplate(w, "index.html", data); err != nil {
log.Fatalln("Unable to execute template.")
}
}
func UploadHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Allowed POST method only", http.StatusMethodNotAllowed)
return
}
err := r.ParseMultipartForm(32 << 20)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fileSrc, _, err := r.FormFile("upload")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer fileSrc.Close()
fileDest, err := os.Create("/tmp/original.jpg")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer fileDest.Close()
io.Copy(fileDest, fileSrc)
log.Println("Original image was saved.")
os.Remove("/tmp/crossprocess.jpg")
http.Redirect(w, r, "/index", http.StatusFound)
}
func CrossProcessHandler(w http.ResponseWriter, r *http.Request) {
hShift, _ := strconv.ParseUint(r.FormValue("hueshift"), 10, 8)
log.Printf("hue shift = %v", hShift)
img := gocv.IMRead("/tmp/original.jpg", gocv.IMReadColor)
defer img.Close()
width := img.Cols()
height := img.Rows()
channels := img.Channels()
imgHSV := gocv.NewMat()
defer imgHSV.Close()
gocv.CvtColor(img, &imgHSV, gocv.ColorBGRToHSVFull)
// Shift HUE
ptrHSV, _ := imgHSV.DataPtrUint8()
fmt.Println("Shifting Hue")
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
ptrHSV[(y*width+x)*channels] = ptrHSV[(y*width+x)*channels] + uint8(hShift)
}
}
imgCrossProcess := gocv.NewMat()
defer imgCrossProcess.Close()
gocv.CvtColor(imgHSV, &imgCrossProcess, gocv.ColorHSVToBGRFull)
// Apply RGB factor
bRate, _ := strconv.ParseFloat(r.FormValue("brate"), 64)
gRate, _ := strconv.ParseFloat(r.FormValue("grate"), 64)
rRate, _ := strconv.ParseFloat(r.FormValue("rrate"), 64)
log.Printf("bRate = %f, gRate = %f, rRate = %f", bRate, gRate, rRate)
ptrBGR, _ := imgCrossProcess.DataPtrUint8()
fmt.Println("Applying RGB factors")
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
ptrBGR[(y*width+x)*channels+0] = uint8(float64(ptrBGR[(y*width+x)*channels+0]) * bRate)
ptrBGR[(y*width+x)*channels+1] = uint8(float64(ptrBGR[(y*width+x)*channels+1]) * gRate)
ptrBGR[(y*width+x)*channels+2] = uint8(float64(ptrBGR[(y*width+x)*channels+2]) * rRate)
}
}
// vignette
vignette := r.FormValue("vignette")
if vignette != "none" {
vFactor := 0.7
if vignette == "high" {
vFactor = 1.0
}
centerX := float64(width / 2)
centerY := float64(height / 2)
fmt.Println("Applying vignette map")
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
nx := (float64(x) - centerX) / centerX
ny := (float64(y) - centerY) / centerY
dist := math.Sqrt(nx*nx+ny*ny) / math.Sqrt(2)
ptrBGR[(y*width+x)*channels+0] = uint8(float64(ptrBGR[(y*width+x)*channels+0]) * (1 - dist*vFactor))
ptrBGR[(y*width+x)*channels+1] = uint8(float64(ptrBGR[(y*width+x)*channels+1]) * (1 - dist*vFactor))
ptrBGR[(y*width+x)*channels+2] = uint8(float64(ptrBGR[(y*width+x)*channels+2]) * (1 - dist*vFactor))
}
}
}
gocv.IMWrite("/tmp/crossprocess.jpg", imgCrossProcess)
log.Println("Cross Process image was saved.")
url := fmt.Sprintf("/index?hueshift=%d;brate=%f;grate=%f;rrate=%f;vignette=%s", hShift, bRate, gRate, rRate, vignette)
http.Redirect(w, r, url, http.StatusFound)
}
func main() {
http.HandleFunc("/", IndexHandler)
http.HandleFunc("/upload", UploadHandler)
http.HandleFunc("/crossprocess", CrossProcessHandler)
http.ListenAndServe(":80", nil)
}
関連記事: Go言語マガジン
――――――――――――――――――――――――――――――――――