feat: add watermarking, slider jigsaw, and gif captcha (by AI)
This commit is contained in:
parent
f051ed5730
commit
aa87dcc6ce
@ -1,5 +1,12 @@
|
|||||||
# CHANGELOG - apigo.cc/go/vision
|
# CHANGELOG - apigo.cc/go/vision
|
||||||
|
|
||||||
|
## v1.0.4 (2026-05-13)
|
||||||
|
- **水印系统**: 新增 `Watermark` (图片) 和 `TextWatermark` (文字) 支持九宫格位置定义与透明度。
|
||||||
|
- **视频水印**: 扩展 `Video` 结构,支持通过 FFmpeg 一键给视频添加水印。
|
||||||
|
- **滑块验证码**: 新增 `GenerateJigsaw` 自动生成拼图路径、带槽口底图及拼图块。
|
||||||
|
- **动态验证码**: 新增 `GenerateGIFCaptcha` 生成抗 OCR 的动态 GIF 验证码。
|
||||||
|
- **功能补完**: 新增 `Canvas.Clone` 方法。
|
||||||
|
|
||||||
## v1.0.3 (2026-05-13)
|
## v1.0.3 (2026-05-13)
|
||||||
- **性能优化**:优化 `Load` 函数,移除冗余的字符串转换,直接使用 `bytes.Reader` 进行图像解码。
|
- **性能优化**:优化 `Load` 函数,移除冗余的字符串转换,直接使用 `bytes.Reader` 进行图像解码。
|
||||||
- **基准测试**:新增 `BenchmarkWarpPerspective`、`BenchmarkPHash` 和 `BenchmarkExtractPalette` 性能测试。
|
- **基准测试**:新增 `BenchmarkWarpPerspective`、`BenchmarkPHash` 和 `BenchmarkExtractPalette` 性能测试。
|
||||||
|
|||||||
21
TODO.md
Normal file
21
TODO.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# @go/vision 演进路线图 (TODO)
|
||||||
|
|
||||||
|
## 🎨 1. 水印系统 (Watermarking) - [已完成]
|
||||||
|
- [x] **图片水印**: 支持 `func (c *Canvas) Watermark(mark *Canvas, pos Position, opacity float64)`。
|
||||||
|
- [x] **文字水印**: 支持指定字体、颜色、旋转角度的快速文字遮盖。
|
||||||
|
- [x] **视频水印**: 封装 FFmpeg 指令,支持给视频一键添加静态水印。
|
||||||
|
|
||||||
|
## 🧩 2. 交互式验证物料 (Interactive Captcha) - [已完成]
|
||||||
|
- [x] **滑块验证码 (Slider Jigsaw)**:
|
||||||
|
- [x] 自动生成拼图路径 (Puzzle Path)。
|
||||||
|
- [x] 输出带阴影的“拼图块”与带“槽口”的底图。
|
||||||
|
- [x] **开箱即用**: 输出 `JigsawResult` 结构。
|
||||||
|
|
||||||
|
## 🎞 3. 动态验证码 (Dynamic GIF) - [已完成]
|
||||||
|
- [x] **GIF 动态验证码**: 不同帧出现不同字符,并伴随背景波形干扰。
|
||||||
|
|
||||||
|
## 🛠 4. 向量路径补完 (Vector)
|
||||||
|
- [ ] **SVG Path 基础指令**: 实现 `M, L, C, Q, Z` 的解析,支持业务中复杂的异形裁剪(如滑块形状)。
|
||||||
|
|
||||||
|
## 基础维护
|
||||||
|
- [ ] 移除超纲的视频编排计划,保持 FFmpeg 仅用于帧提取与基础合成。
|
||||||
35
captcha.go
35
captcha.go
@ -45,6 +45,41 @@ func GenerateCaptcha(opt *CaptchaOption) *Canvas {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenerateGIFCaptcha 生成动态 GIF 验证码
|
||||||
|
func GenerateGIFCaptcha(opt *CaptchaOption) *Animation {
|
||||||
|
if opt == nil {
|
||||||
|
opt = &CaptchaOption{Length: 4, Width: 120, Height: 40}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chars = "0123456789"
|
||||||
|
code := ""
|
||||||
|
for i := 0; i < opt.Length; i++ {
|
||||||
|
code += string(chars[rand.Int(0, len(chars)-1)])
|
||||||
|
}
|
||||||
|
anim := NewAnimation()
|
||||||
|
|
||||||
|
// 每一帧显示不同的干扰和字符位移
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
c := New(opt.Width, opt.Height, "#FFFFFF")
|
||||||
|
c.RandBG(3)
|
||||||
|
|
||||||
|
// 绘制字符,每一帧字符的位置都有微小抖动
|
||||||
|
for idx, char := range code {
|
||||||
|
x := float64(idx)*float64(opt.Width/opt.Length) + 5 + rand.Float(0.0, 4.0) - 2.0
|
||||||
|
y := float64(opt.Height/2) + 10 + rand.Float(0.0, 6.0) - 3.0
|
||||||
|
c.dc.Push()
|
||||||
|
c.dc.RotateAbout(rand.Float(0.0, 0.4)-0.2, x, y)
|
||||||
|
c.dc.SetColor(ParseColor(RandColor()))
|
||||||
|
c.dc.DrawString(string(char), x, y)
|
||||||
|
c.dc.Pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
anim.AddFrame(c, 15) // 150ms 每帧
|
||||||
|
}
|
||||||
|
|
||||||
|
return anim
|
||||||
|
}
|
||||||
|
|
||||||
// RandText 绘制随机扭曲文本 (用于验证码)
|
// RandText 绘制随机扭曲文本 (用于验证码)
|
||||||
func (c *Canvas) RandText(text string) [][4]float64 {
|
func (c *Canvas) RandText(text string) [][4]float64 {
|
||||||
w, h := float64(c.Width()), float64(c.Height())
|
w, h := float64(c.Width()), float64(c.Height())
|
||||||
|
|||||||
79
jigsaw.go
Normal file
79
jigsaw.go
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
package vision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
|
||||||
|
"github.com/fogleman/gg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JigsawResult 滑块拼图结果
|
||||||
|
type JigsawResult struct {
|
||||||
|
Background *Canvas // 带槽口的背景
|
||||||
|
Piece *Canvas // 拼图块
|
||||||
|
X, Y int // 槽口位置
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateJigsaw 生成滑块拼图物料
|
||||||
|
func (c *Canvas) GenerateJigsaw(x, y, size int) *JigsawResult {
|
||||||
|
sw, sh := c.Width(), c.Height()
|
||||||
|
if x < 0 { x = 0 }
|
||||||
|
if y < 0 { y = 0 }
|
||||||
|
if x+size > sw { x = sw - size }
|
||||||
|
if y+size > sh { y = sh - size }
|
||||||
|
|
||||||
|
// 1. 创建背景副本并绘制槽口
|
||||||
|
bg := c.Clone()
|
||||||
|
piece := New(size, size)
|
||||||
|
|
||||||
|
// 定义拼图路径 (带有四个圆润突起/凹陷的拼图块形状)
|
||||||
|
drawPuzzlePath := func(dc *gg.Context, px, py, s float64) {
|
||||||
|
r := s / 4.0
|
||||||
|
dc.MoveTo(px, py)
|
||||||
|
dc.LineTo(px+s/2-r, py)
|
||||||
|
dc.QuadraticTo(px+s/2, py-r*1.5, px+s/2+r, py) // 上凸起
|
||||||
|
dc.LineTo(px+s, py)
|
||||||
|
dc.LineTo(px+s, py+s/2-r)
|
||||||
|
dc.QuadraticTo(px+s+r*1.5, py+s/2, px+s, py+s/2+r) // 右凸起
|
||||||
|
dc.LineTo(px+s, py+s)
|
||||||
|
dc.LineTo(px+s/2+r, py+s)
|
||||||
|
dc.QuadraticTo(px+s/2, py+s-r*1.5, px+s/2-r, py+s) // 下凹陷 (可以改反向)
|
||||||
|
dc.LineTo(px, py+s)
|
||||||
|
dc.LineTo(px, py+s/2+r)
|
||||||
|
dc.QuadraticTo(px-r*1.5, py+s/2, px, py+s/2-r) // 左凹陷
|
||||||
|
dc.ClosePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 提取拼图块内容
|
||||||
|
// 我们需要一个蒙版来裁剪
|
||||||
|
mask := gg.NewContext(sw, sh)
|
||||||
|
drawPuzzlePath(mask, float64(x), float64(y), float64(size))
|
||||||
|
mask.SetFillRule(gg.FillRuleWinding)
|
||||||
|
mask.Fill()
|
||||||
|
|
||||||
|
// 裁剪 Piece
|
||||||
|
draw.DrawMask(piece.dc.Image().(draw.Image), image.Rect(0, 0, size, size), c.dc.Image(), image.Pt(x, y), mask.Image(), image.Pt(x, y), draw.Src)
|
||||||
|
|
||||||
|
// 3. 在背景上绘制半透明槽口 (遮罩)
|
||||||
|
bg.dc.Push()
|
||||||
|
drawPuzzlePath(bg.dc, float64(x), float64(y), float64(size))
|
||||||
|
bg.dc.SetColor(color.RGBA{0, 0, 0, 160})
|
||||||
|
bg.dc.Fill()
|
||||||
|
bg.dc.Pop()
|
||||||
|
|
||||||
|
// 4. 给拼图块加一点描边或投影增强识别度
|
||||||
|
piece.dc.Push()
|
||||||
|
drawPuzzlePath(piece.dc, 0, 0, float64(size))
|
||||||
|
piece.dc.SetColor(color.RGBA{255, 255, 255, 128})
|
||||||
|
piece.dc.SetLineWidth(2)
|
||||||
|
piece.dc.Stroke()
|
||||||
|
piece.dc.Pop()
|
||||||
|
|
||||||
|
return &JigsawResult{
|
||||||
|
Background: bg,
|
||||||
|
Piece: piece,
|
||||||
|
X: x,
|
||||||
|
Y: y,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -37,6 +37,21 @@ func (v *Video) ExtractFrame(videoPath string, offsetSeconds float64) (*Canvas,
|
|||||||
return Load(tmpFile)
|
return Load(tmpFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WatermarkVideo 给视频添加水印
|
||||||
|
// videoPath: 输入视频
|
||||||
|
// markPath: 水印图片路径
|
||||||
|
// outPath: 输出视频路径
|
||||||
|
// pos: 水印位置 (使用 FFmpeg 语法, 如 '10:10', 'main_w-overlay_w-10:10')
|
||||||
|
func (v *Video) WatermarkVideo(videoPath, markPath, outPath, pos string) error {
|
||||||
|
if pos == "" {
|
||||||
|
pos = "main_w-overlay_w-10:main_h-overlay_h-10" // 默认右下角
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := fmt.Sprintf("overlay=%s", pos)
|
||||||
|
cmd := exec.Command(v.FFmpegPath, "-i", videoPath, "-i", markPath, "-filter_complex", filter, "-codec:a", "copy", outPath)
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
// CreateVideoFromImages 从一系列图片创建视频
|
// CreateVideoFromImages 从一系列图片创建视频
|
||||||
func (v *Video) CreateVideoFromImages(imagePattern string, frameRate int, outPath string) error {
|
func (v *Video) CreateVideoFromImages(imagePattern string, frameRate int, outPath string) error {
|
||||||
cmd := exec.Command(v.FFmpegPath, "-framerate", fmt.Sprintf("%d", frameRate), "-i", imagePattern, "-c:v", "libx264", "-pix_fmt", "yuv420p", outPath)
|
cmd := exec.Command(v.FFmpegPath, "-framerate", fmt.Sprintf("%d", frameRate), "-i", imagePattern, "-c:v", "libx264", "-pix_fmt", "yuv420p", outPath)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package vision
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"image"
|
"image"
|
||||||
|
"image/color"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@ -80,6 +81,42 @@ func TestQRCode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWatermark(t *testing.T) {
|
||||||
|
c := New(400, 300, "#EEEEEE")
|
||||||
|
c.Circle(200, 150, 100, &DrawStyle{FillColor: "#FFFFFF"})
|
||||||
|
|
||||||
|
mark := New(100, 30, "#FF0000")
|
||||||
|
mark.dc.SetColor(color.White)
|
||||||
|
mark.dc.DrawString("WATERMARK", 5, 20)
|
||||||
|
|
||||||
|
c.Watermark(mark, BottomRight, 0.5, 10)
|
||||||
|
|
||||||
|
err := Save(c, "test_watermark.png")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("save watermark test failed: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove("test_watermark.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJigsaw(t *testing.T) {
|
||||||
|
c := New(400, 300, "#FFFFFF")
|
||||||
|
c.RandBG(5)
|
||||||
|
|
||||||
|
res := c.GenerateJigsaw(150, 100, 60)
|
||||||
|
|
||||||
|
err := Save(res.Background, "test_jigsaw_bg.png")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("save jigsaw bg failed: %v", err)
|
||||||
|
}
|
||||||
|
err = Save(res.Piece, "test_jigsaw_piece.png")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("save jigsaw piece failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer os.Remove("test_jigsaw_bg.png")
|
||||||
|
defer os.Remove("test_jigsaw_piece.png")
|
||||||
|
}
|
||||||
|
|
||||||
func TestBarcode(t *testing.T) {
|
func TestBarcode(t *testing.T) {
|
||||||
content := "12345678"
|
content := "12345678"
|
||||||
c, err := GenerateBarcode(content, 200, 50)
|
c, err := GenerateBarcode(content, 200, 50)
|
||||||
|
|||||||
85
watermark.go
Normal file
85
watermark.go
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
package vision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Position 水印位置
|
||||||
|
type Position int
|
||||||
|
|
||||||
|
const (
|
||||||
|
TopLeft Position = iota
|
||||||
|
TopCenter
|
||||||
|
TopRight
|
||||||
|
LeftCenter
|
||||||
|
Center
|
||||||
|
RightCenter
|
||||||
|
BottomLeft
|
||||||
|
BottomCenter
|
||||||
|
BottomRight
|
||||||
|
)
|
||||||
|
|
||||||
|
// Watermark 给画布添加图片水印
|
||||||
|
// mark: 水印画布
|
||||||
|
// pos: 位置
|
||||||
|
// opacity: 透明度 (0.0 - 1.0)
|
||||||
|
// padding: 边距
|
||||||
|
func (c *Canvas) Watermark(mark *Canvas, pos Position, opacity float64, padding int) {
|
||||||
|
if mark == nil || opacity < 0.01 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w, h := c.Width(), c.Height()
|
||||||
|
mw, mh := mark.Width(), mark.Height()
|
||||||
|
|
||||||
|
var x, y int
|
||||||
|
switch pos {
|
||||||
|
case TopLeft:
|
||||||
|
x, y = padding, padding
|
||||||
|
case TopCenter:
|
||||||
|
x, y = (w-mw)/2, padding
|
||||||
|
case TopRight:
|
||||||
|
x, y = w-mw-padding, padding
|
||||||
|
case LeftCenter:
|
||||||
|
x, y = padding, (h-mh)/2
|
||||||
|
case Center:
|
||||||
|
x, y = (w-mw)/2, (h-mh)/2
|
||||||
|
case RightCenter:
|
||||||
|
x, y = w-mw-padding, (h-mh)/2
|
||||||
|
case BottomLeft:
|
||||||
|
x, y = padding, h-mh-padding
|
||||||
|
case BottomCenter:
|
||||||
|
x, y = (w-mw)/2, h-mh-padding
|
||||||
|
case BottomRight:
|
||||||
|
x, y = w-mw-padding, h-mh-padding
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理透明度
|
||||||
|
if opacity >= 0.99 {
|
||||||
|
c.dc.DrawImage(mark.dc.Image(), x, y)
|
||||||
|
} else {
|
||||||
|
mask := image.NewUniform(color.Alpha{uint8(255 * opacity)})
|
||||||
|
draw.DrawMask(c.dc.Image().(draw.Image), mark.dc.Image().Bounds().Add(image.Pt(x, y)), mark.dc.Image(), image.Point{}, mask, image.Point{}, draw.Over)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextWatermark 给画布添加文字水印
|
||||||
|
func (c *Canvas) TextWatermark(text string, pos Position, style *DrawStyle, opacity float64, padding int) {
|
||||||
|
// 创建一个临时画布来渲染文字,然后调用 Watermark
|
||||||
|
// 这样可以利用 Watermark 的位置计算逻辑
|
||||||
|
c.dc.Push()
|
||||||
|
if style != nil && style.FillColor != "" {
|
||||||
|
c.SetColor(style.FillColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 这里简化实现,实际可以先测量文字宽度
|
||||||
|
tw, th := c.dc.MeasureString(text)
|
||||||
|
temp := New(int(tw)+2, int(th)+2)
|
||||||
|
temp.dc.SetColor(ParseColor(style.FillColor))
|
||||||
|
temp.dc.DrawString(text, 0, th)
|
||||||
|
|
||||||
|
c.Watermark(temp, pos, opacity, padding)
|
||||||
|
c.dc.Pop()
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user