之前写了一个 Go 脚本,可以给图片打上倾斜的水印,当时只是为了保护一些在线发布的图不被直接盗用。虽然脚本能用,但每次都要命令行输入路径、调参数,体验并不好。
后来我就在想,如果做一个网页,把这套逻辑封装成“上传主图 + 上传水印 + 设置参数 + 一键生成”,是不是会更方便,也更适合自己和别人使用?
这个小项目说简单不简单,说复杂也还行。它跨了前端页面 + 后端服务 + 图像处理三个部分,我们先把整体结构和用到的技术讲清楚。
Go 自带的 net/http
足够轻量,拿来做文件上传和图像处理再合适不过。加上标准库里的 image
、draw
,处理 PNG/JPEG 没压力。
此外,我们还用了一个小库:github.com/anthonynsimon/bild/transform
,它的 Rotate()
函数可以让我们轻松地把水印图像倾斜任意角度。
没有用 Vue、React,就用最基本的 HTML 表单 + 一点 JavaScript。上传图片、填写参数、提交表单、展示返回的处理结果图。这样做轻便、好部署。
项目的目录结构是这样的:
go-watermark-web/
├── main.go // 后端主程序
├── processor/ // 图像处理逻辑(可选模块化)
│ └── watermark.go
├── static/ // 前端页面和 JS
│ ├── index.html
│ └── script.js
├── uploads/ // 临时保存上传的图
└── output/ // 最终生成的水印图
你启动服务之后,访问 http://localhost:8080,就能看到上传页面,上传完图片后,返回结果图直接就能右键保存或者拖到桌面。
这一部分是整个项目的“中枢大脑”。用户在网页上点了上传,其实就是把两张图 + 一堆参数 POST 到我们的后端接口 /upload
,Go 后端负责接收这些数据,然后把图片处理好、保存、再把结果图的路径返回给前端。
下面是完整的 main.go
文件,我们会一边讲逻辑,一边贴代码。
package main
import (
"bytes"
"fmt"
"image"
"image/color"
"image/draw"
"image/jpeg"
"image/png"
"io"
"log"
"math"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/anthonynsimon/bild/transform"
)
这里除了标准库的图像和 Web 相关模块外,我们引入了 transform
用来处理图像的旋转。
func main() {
http.HandleFunc("/", servePage)
http.HandleFunc("/upload", handleUpload)
http.Handle("/output/", http.StripPrefix("/output/", http.FileServer(http.Dir("output"))))
log.Println("服务已启动,访问 http://localhost:8080")
http.ListenAndServe(":8080", nil)
}
这段代码里我们干了三件事:
/
路由返回上传页面(HTML 页面)/upload
是用户提交表单的处理入口/output/
是结果图的访问路径(前端点开结果图就是通过这个路径)func servePage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "static/index.html")
}
很简单,就是把 HTML 页面从 static 目录读出来发给用户。
func handleUpload(w http.ResponseWriter, r *http.Request) {
r.ParseMultipartForm(10 << 20) // 限制上传文件大小为 10MB
mainFile, _, err := r.FormFile("mainImage")
if err != nil {
http.Error(w, "主图上传失败", http.StatusBadRequest)
return
}
defer mainFile.Close()
watermarkFile, _, err := r.FormFile("watermark")
if err != nil {
http.Error(w, "水印图上传失败", http.StatusBadRequest)
return
}
defer watermarkFile.Close()
这里是处理上传文件的部分:读取两张图片。
接下来,我们要读取前端传来的水印参数。
angle, _ := strconv.ParseFloat(r.FormValue("angle"), 64)
opacity, _ := strconv.Atoi(r.FormValue("opacity"))
dx, _ := strconv.Atoi(r.FormValue("dx"))
dy, _ := strconv.Atoi(r.FormValue("dy"))
这些值分别是:
然后开始处理图像:
mainImg, format, err := decodeImage(mainFile)
if err != nil {
http.Error(w, "主图格式错误", http.StatusInternalServerError)
return
}
_, _ = mainFile.Seek(0, io.SeekStart) // 重置 file 指针
wmImg, _, err := decodeImage(watermarkFile)
if err != nil {
http.Error(w, "水印图格式错误", http.StatusInternalServerError)
return
}
result := applyWatermark(mainImg, wmImg, angle, uint8(opacity), dx, dy)
图像解码后,调用 applyWatermark()
处理图片,返回的新图就是结果图。
最后是保存输出:
timestamp := time.Now().Unix()
outPath := fmt.Sprintf("output/result_%d.%s", timestamp, format)
outFile, _ := os.Create(outPath)
defer outFile.Close()
switch format {
case "jpeg":
jpeg.Encode(outFile, result, nil)
case "png":
png.Encode(outFile, result)
default:
http.Error(w, "不支持的输出格式", http.StatusInternalServerError)
return
}
w.Write([]byte("/output/" + filepath.Base(outPath)))
}
func decodeImage(file io.Reader) (image.Image, string, error) {
data, err := io.ReadAll(file)
if err != nil {
return nil, "", err
}
img, format, err := image.Decode(bytes.NewReader(data))
return img, format, err
}
用 image.Decode()
自动识别图片格式(PNG/JPEG),转成 image.Image。
后端的处理流程已经准备好了,现在我们来做前端页面。
这个项目的前端很轻量,用原生 HTML 就够了,毕竟只是做上传和参数填写,不需要框架也能很好用。
我们把前端页面放在 static/index.html
,所有静态资源都可以通过 http.ServeFile
或 FileServer
提供。
index.html
:上传表单页面<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>在线图片加水印工具</title>
<style>
body {
font-family: sans-serif;
max-width: 600px;
margin: 40px auto;
}
input, button {
margin: 10px 0;
display: block;
}
img {
max-width: 100%;
border: 1px solid #ccc;
margin-top: 20px;
}
</style>
</head>
<body>
<h2>在线水印生成器</h2>
<form id="uploadForm">
<label>主图:</label>
<input type="file" name="mainImage" required>
<label>水印图:</label>
<input type="file" name="watermark" required>
<label>旋转角度(°):</label>
<input type="number" name="angle" value="30" step="1">
<label>透明度(0-100):</label>
<input type="number" name="opacity" value="40" step="1">
<label>X 间距(像素):</label>
<input type="number" name="dx" value="50" step="1">
<label>Y 间距(像素):</label>
<input type="number" name="dy" value="50" step="1">
<button type="submit">生成水印图片</button>
</form>
<div id="result"></div>
<script src="script.js"></script>
</body>
</html>
这个表单页面很直观,主要字段是:
<input type="file">
:上传主图和水印图用户填写参数后点击按钮,触发 JS 提交表单。
script.js
:提交表单并展示结果const form = document.getElementById('uploadForm')
const result = document.getElementById('result')
form.addEventListener('submit', async (e) => {
e.preventDefault()
const formData = new FormData(form)
result.innerHTML = '正在上传并生成,请稍等...'
const resp = await fetch('/upload', {
method: 'POST',
body: formData
})
if (!resp.ok) {
const err = await resp.text()
result.innerHTML = '出错了:' + err
return
}
const imageUrl = await resp.text()
result.innerHTML = `
<p>生成成功,点击下图可查看原图:</p>
<a href="${imageUrl}" target="_blank">
<img src="${imageUrl}" alt="处理结果图">
</a>
`
})
这段 JS 做了几件事:
fetch
异步 POST 上传图片和参数我们没有用任何前端框架,主要是为了部署简单、代码透明。如果你用 React 或 Vue,也可以封装成组件,但对一个简单工具来说,这种方式足够清爽。
到这一步,用户在浏览器访问首页、上传图片并填写参数后,后端就能接收到所有信息并处理返回图像了。
我们要实现的,是把一张水印图按照设定角度倾斜旋转,然后在主图上平铺排列,最后根据设定的透明度进行融合,生成一张“铺满水印”的图片。
applyWatermark
我们将整个图像处理抽象为一个函数:
func applyWatermark(base image.Image, watermark image.Image, angle float64, opacity uint8, dx int, dy int) image.Image
参数说明:
base
:主图watermark
:水印图angle
:旋转角度(单位是度)opacity
:透明度(0-255)dx
、dy
:水印之间的水平和垂直间距func applyWatermark(base image.Image, watermark image.Image, angle float64, opacity uint8, dx int, dy int) image.Image {
b := base.Bounds()
canvas := image.NewRGBA(b)
// 先把主图画到 canvas 上
draw.Draw(canvas, b, base, b.Min, draw.Src)
// 旋转水印图
rotated := transform.Rotate(watermark, angle, color.Transparent)
wmBounds := rotated.Bounds()
// 在主图区域内循环平铺水印
for y := b.Min.Y; y < b.Max.Y; y += wmBounds.Dy() + dy {
for x := b.Min.X; x < b.Max.X; x += wmBounds.Dx() + dx {
drawTransparent(canvas, rotated, x, y, opacity)
}
}
return canvas
}
canvas
是我们要最终输出的图像base
画到 canvas
上bild/transform
库)for
循环,在主图上进行平铺叠加drawTransparent
这个函数将水印图画到目标图上,并处理透明度:
func drawTransparent(dst *image.RGBA, src image.Image, xOffset, yOffset int, alpha uint8) {
sb := src.Bounds()
for y := 0; y < sb.Dy(); y++ {
for x := 0; x < sb.Dx(); x++ {
sr := src.At(sb.Min.X+x, sb.Min.Y+y)
r, g, b, a := sr.RGBA()
// 如果像素完全透明,跳过
if a == 0 {
continue
}
// 计算最终透明度:原图像素 alpha * 全局 alpha
finalAlpha := uint8((uint32(alpha) * a) >> 16 >> 8)
dst.Set(xOffset+x, yOffset+y, color.NRGBA{
R: uint8(r >> 8),
G: uint8(g >> 8),
B: uint8(b >> 8),
A: finalAlpha,
})
}
}
}
解释一下关键点:
uint32
,我们要右移 >> 8
把它缩小到 uint8
最终保存图像时,我们用下面的方式判断输出格式:
switch format {
case "jpeg":
jpeg.Encode(outFile, result, nil)
case "png":
png.Encode(outFile, result)
}
这个 format
是之前从主图解析时 image.Decode()
自动识别出来的,基本能满足常见图片场景。
如果你用一张 600x400 的 PNG 图作为水印,加在一张 1080x720 的 JPEG 图上,设定角度为 30°、透明度为 30、间距为 50px,那么最终得到的图上会出现一整屏平铺斜着的 logo,肉眼看得清楚但不影响整体观感,足够防盗图使用。
到目前为止,我们已经实现了:
现在我们从用户角度出发,梳理一遍这个项目是怎么工作的。
浏览器打开 http://localhost:8080
,加载的是我们在 static/index.html
中写的表单页面。这个页面里包含了两个图片上传项,以及四个参数输入框。
用户点击“上传主图”、“上传水印”,再填上角度、透明度、间距,点击“生成水印图片”。
我们在 script.js
中注册了 submit
事件监听器:
form.addEventListener('submit', async (e) => {
e.preventDefault()
const formData = new FormData(form)
const resp = await fetch('/upload', {
method: 'POST',
body: formData
})
const imageUrl = await resp.text()
result.innerHTML = `<img src=\"${imageUrl}\" alt=\"结果图\">`
})
这段 JS 会把整张表单打包为 FormData
,然后发给 /upload
,并接收后端返回的图片地址。前端会自动将处理好的图展示在网页上。
/upload
接收处理进入 handleUpload()
函数后,流程大致如下:
applyWatermark()
处理output/result_xxx.png
/output/result_xxx.png
前端收到返回路径,立刻将图片展示在页面上,点击还能放大查看,或者右键另存为本地图片,整个操作链路不需要刷新页面。
uploads/
:可选临时目录(我们现在没用上,因为不保留原图)output/
:处理后的图片都保存在这个目录下,注意定期清理static/
:前端页面文件夹main.go
:整个服务的入口processor/
(如果你模块化图像逻辑):水印处理相关逻辑由于 Go 的 net/http
默认是支持并发的,所以理论上这个工具可以同时处理多个用户的请求。但你需要注意:
output/
是共享目录,最好加个唯一文件名(时间戳 + 随机数)项目目前是无状态服务,比较适合跑在本地、内网或私有小工具环境中。如果要上公网,建议加上上传限制、鉴权、访问频率控制等。
这个项目非常轻量级,不依赖数据库、不依赖前端框架,也没有用什么复杂的构建工具。只要你电脑里装了 Go 环境(建议 1.18+),就可以直接运行起来。
你只需要一个依赖库:bild
打开终端,在项目目录执行:
go mod init go-watermark-web
go get github.com/anthonynsimon/bild
这两步做了什么?
go.mod
文件,初始化模块管理transform.Rotate()
所需的库确保你的目录结构如下:
go-watermark-web/
├── main.go
├── static/
│ ├── index.html
│ └── script.js
├── output/
终端执行:
go run main.go
然后浏览器访问:
http://localhost:8080
你就可以上传图片并生成水印图了。
go build -o watermark-server
执行完你会得到一个可执行文件 watermark-server
(Windows 是 .exe
),直接运行它就行:
./watermark-server
有几种方案:
go run main.go
,记得放行 8080 端口yourdomain.com/watermark
output/
目录里的旧图(可以用 cron 定时删除)至此,我们整个项目已经从“能跑”变成了“能部署”,你可以拿它当一个可用的小工具,也可以继续扩展功能。
这一路从最开始的脚本,到现在的可运行网页工具,其实也就几百行代码,但你能明显感觉到那种从“工具”到“产品雏形”的转变。
这个项目做完之后,至少收获了这些:
最初那个脚本,每次要修改路径、改参数、命令行跑一遍。现在,打开网页、点两下、调几个数字,马上就能生成水印图。
而且任何人,只要有浏览器就能用。你自己用舒服了,同事、朋友都可以分享给他们用。
我们把图像处理逻辑单独抽成了函数(甚至可以模块化到 processor/watermark.go
),前端页面和后端逻辑隔离清晰,项目结构很容易维护和扩展。
未来你加功能,比如支持多图批量上传,或者加入文字水印,其实完全不难。
从“命令行工具”变成“能让人用起来的东西”,哪怕只是局域网部署的页面,这就是从开发者到 Maker 的第一步。
这个项目看起来很小,实际上却涵盖了:
它就是一个完整的小应用,一个“从 0 到 1”的项目闭环。
你可以把它作为练手项目、作品集之一,也可以继续拓展,把它做得更实用、更漂亮、更强大。
祝你使用愉快,也欢迎你把这个项目发到 GitHub,顺便点个 Star~😉
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。