Compare commits

..

No commits in common. "main" and "v1.0.0" have entirely different histories.
main ... v1.0.0

18 changed files with 51 additions and 762 deletions

View File

@ -1,34 +1,5 @@
# CHANGELOG - apigo.cc/go/vision
## v1.0.5 (2026-05-13)
- **高级水印系统**:
- 为 `Watermark``TextWatermark` 增加旋转角度 (`angle`) 支持。
- 新增 `TileWatermark``TileTextWatermark` 实现全图平铺水印,支持自定义间距与角度。
- **GIF 水印支持**: 为 `Animation` 结构增加全套水印方法,支持对动图所有帧批量添加水印。
- **状态确认**: 确认并完善了二维码 (`QR Code`) 与条形码 (`Barcode`) 的生成与识别能力。
## v1.0.4 (2026-05-13)
- **水印系统**: 新增 `Watermark` (图片) 和 `TextWatermark` (文字) 支持九宫格位置定义与透明度。
- **视频水印**: 扩展 `Video` 结构,支持通过 FFmpeg 一键给视频添加水印。
- **滑块验证码**: 新增 `GenerateJigsaw` 自动生成拼图路径、带槽口底图及拼图块。
- **动态验证码**: 新增 `GenerateGIFCaptcha` 生成抗 OCR 的动态 GIF 验证码。
- **功能补完**: 新增 `Canvas.Clone` 方法。
## v1.0.3 (2026-05-13)
- **性能优化**:优化 `Load` 函数,移除冗余的字符串转换,直接使用 `bytes.Reader` 进行图像解码。
- **基准测试**:新增 `BenchmarkWarpPerspective``BenchmarkPHash``BenchmarkExtractPalette` 性能测试。
- **防御性编程**:在 `Load` 函数中增加路径非空检查,提升健壮性。
- **文档更新**:在 `TEST.md` 中同步性能基准指标。
## v1.0.2 (2026-05-12)
- **文档增强**:重构 `README.md`,增加透视变换、动画合成、验证码生成等深度示例。
- **发布测试指南**:新增 `TEST.md` 明确测试覆盖范围与验证流程。
- **API 完善**:在 `Canvas` 中新增 `Invert` 滤镜支持。
## v1.0.1 (2026-05-12)
- **基础设施对齐**:全面移除原生 `os``strconv` 依赖,强制对齐 `@go` 核心设施。
- **内存优化**:使用 `go/file` 支持内存中的图像处理与序列化。
## v1.0.0 (2026-05-12)
* **Initial Release**: Complete migration and evolution from `@gojs/img`.

21
LICENSE
View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 ssgo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -4,7 +4,7 @@
## 🎯 设计哲学
`go/vision` 致力于消除 Go 语言在媒体处理领域的摩擦。通过纯 Go 的核心算法与标准化的外部工具编排,提供一套语义一致、零摩擦、高性能一站式 API 体系。
`go/vision` 致力于消除 Go 语言在媒体处理领域的摩擦。通过纯 Go 的核心算法与标准化的外部工具编排,提供一套语义一致、零摩擦、高性能 API 体系。
* **零摩擦**: 自动探测/引导环境准备(如 FFmpeg一键式识别与转换。
* **工业级**: 错误驱动架构No internal logging完备的单元测试覆盖。
@ -18,14 +18,13 @@
### 2. 图像处理与变换
* **几何变换**: 缩放 (Resize/Fit/Fill)、旋转、镜像、**4 点透视变换 (WarpPerspective)**。
* **高级滤镜**: 模糊、锐化、灰度、亮度/对比度、色彩反转、卷积滤波
* **高级滤镜**: 模糊、锐化、灰度、亮度/对比度、怀旧 (Sepia)、像素化
* **色彩分析**: 调色板提取 (`ExtractPalette`)、平均色计算。
### 3. 智能视觉 (Intelligence)
* **码码识别**: 集成 QR Code、条形码 (Code128, UPC/EAN) 的生成与自动解码识别。
* **感知哈希 (PHash)**: 基于图像特征的指纹计算,用于海量图片相似度查重。
* **验证码引擎**: 高强度抗 OCR 图形验证码生成。
* **模板匹配**: `FindTemplate` 支持在大图中精准定位子图。
### 4. 动态媒体 (Animation & Video)
* **GIF 引擎**: 高质量 GIF 序列生成,内置 `Plan9` 调色板与 `Floyd-Steinberg` 抖动。
@ -39,77 +38,37 @@ go get apigo.cc/go/vision
## 💡 快速开始
### 1. 扫码识别
### 扫码识别
```go
// 自动尝试 QR 和条码识别
c, _ := vision.Load("code.jpg")
content, err := c.DecodeAll()
// 生成二维码并保存
qr, _ := vision.GenerateQRCode("https://apigo.cc", 256)
vision.Save(qr, "qr.png")
content, err := c.DecodeAll() // 自动尝试 QR 和条码
```
### 2. 透视变换 (WarpPerspective)
常用于文档扫描纠偏。
```go
c, _ := vision.Load("skewed_doc.jpg")
// 指定源图中的四个角点 (TL, TR, BR, BL)
srcPoints := [4]image.Point{
{150, 20}, {450, 50}, {480, 380}, {100, 350},
}
c.WarpPerspective(srcPoints, 300, 400)
vision.Save(c, "flat_doc.png")
```
### 3. 生成 GIF 动画
```go
anim := vision.NewAnimation()
for i := 0; i < 10; i++ {
c := vision.New(100, 100, "#FFFFFF")
c.Circle(50, 50, float64(i*5), &vision.DrawStyle{FillColor: "#FF0000"})
anim.AddFrame(c, 10) // 100ms 延迟
}
anim.SaveGIF("motion.gif", 0) // 0 表示无限循环
```
### 4. 视频帧提取
### 视频帧处理
```go
v, _ := vision.NewVideo()
frame, _ := v.ExtractFrame("movie.mp4", 5.0) // 提取第 5 秒的帧
frame.Blur(2.0)
vision.Save(frame, "preview.jpg")
frame, _ := v.ExtractFrame("video.mp4", 5.0)
frame.Grayscale()
vision.Save(frame, "snapshot.png")
```
### 5. 多媒体预览与转写转码
针对 Web 端、列表缩略图或语音转写场景的一站式优化预览。支持自动缩放并裁剪以适应指定尺寸。
### 提取主色调
```go
// 生成图片缩略图 (WebP, 自动填充/裁剪)
vision.GenerateImagePreview("photo.jpg", "thumb.webp", 200, 200)
// 生成 4 帧动画 WebP (自动填充/裁剪, 效果动态)
vision.GenerateVideoPreview("movie.mp4", "preview.webp", 320, 180)
// 提取音频预览片段 (16kHz Ogg Opus, 最长 3 分钟)
vision.GenerateAudioPreview("input.mp4", "preview.ogg")
palette := canvas.ExtractPalette(5)
for _, c := range palette {
fmt.Println("发现主色:", c.Hex)
}
```
## 🛠 API 概览
| 模块 | 主要 API |
| :--- | :--- |
| **Canvas** | `New`, `Load`, `Save`, `Clear`, `Sub`, `Clone`, `Put`, `LoadFonts` |
| **Canvas** | `New`, `Load`, `Save`, `Clear`, `Sub`, `Clone`, `Put` |
| **Draw** | `Rect`, `RoundedRect`, `Circle`, `Line`, `Path`, `RandBG` |
| **Effect** | `Resize`, `Rotate`, `Blur`, `Sharpen`, `AdjustBrightness`, `Grayscale`, `Invert` |
| **Transform** | `WarpPerspective`, `FlipH`, `FlipV` |
| **Recognition** | `DecodeQRCode`, `DecodeBarcode`, `DecodeAll`, `PHash`, `Distance`, `FindTemplate` |
| **Media** | `NewAnimation`, `NewVideo`, `ProcessVideoFrames`, `DiffFrames` |
## ⚙️ 环境依赖
* **FFmpeg**: 视频处理模块依赖 `ffmpeg` 二进制文件。
* `vision.NewVideo()` 会尝试自动探测系统路径。
* 如果未安装,它会提示下载路径或尝试自动引导(取决于权限)。
| **Effect** | `Resize`, `Rotate`, `Blur`, `Sharpen`, `AdjustBrightness`, `Grayscale` |
| **Recognition** | `DecodeQRCode`, `DecodeBarcode`, `DecodeAll`, `PHash`, `Distance` |
| **Media** | `NewAnimation`, `NewVideo`, `ConvertAll`, `Optimize` |
---
本项目由 AI 驱动开发与维护,遵循极致的代码质量与性能标准。

45
TEST.md
View File

@ -1,45 +0,0 @@
# Testing @go/vision
`go/vision` 拥有完善的单元测试覆盖,确保在各种图像处理场景下的稳定性。
## 运行测试
`vision` 目录下运行标准 Go 测试命令:
```bash
go test -v .
```
## 测试覆盖范围
* **Canvas & Drawing**: 验证基础绘图、颜色解析、图层叠加等功能。
* **Intelligence**:
* `QRCode`: 验证二维码的生成与识别一致性。
* `Barcode`: 验证条形码 (Code128, UPC) 的生成与识别。
* `PHash`: 验证相似图片的指纹距离计算。
* **Captcha**: 验证图形验证码的生成。
* **Transform**: 验证缩放、旋转以及复杂的 `WarpPerspective` 透视变换。
* **Animation**: 验证 GIF 序列的合成。
## 视觉回归测试
部分测试会生成临时的图片文件(如 `test.png`, `captcha.png`),测试脚本会自动清理这些文件。在开发新滤镜或绘图功能时,建议手动查看生成的图片以确保视觉效果符合预期。
## 性能基准测试
可以使用以下命令运行基准测试:
```bash
go test -bench .
```
以下是在 Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz 环境下的基准测试结果:
| 测试项 | 耗时 (ns/op) |
| :--- | :--- |
| **WarpPerspective** | 7,079,540 |
| **PHash** | 958,618 |
| **ExtractPalette** | 402,176 |
---
所有测试均遵循 `@go` 基础设施标准,无外部系统依赖(除 FFmpeg 视频测试外,该部分会自动跳过或提示引导)。

21
TODO.md
View File

@ -1,21 +0,0 @@
# @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 仅用于帧提取与基础合成。

View File

@ -5,8 +5,8 @@ import (
"image/color/palette"
"image/draw"
"image/gif"
"os"
"apigo.cc/go/file"
"github.com/fogleman/gg"
)
@ -51,7 +51,7 @@ func (a *Animation) SaveGIF(path string, loopCount int) error {
out.Delay = append(out.Delay, a.Delays[i])
}
f, err := file.Create(path)
f, err := os.Create(path)
if err != nil {
return err
}
@ -62,7 +62,7 @@ func (a *Animation) SaveGIF(path string, loopCount int) error {
// LoadGIF 从文件加载 GIF 动画
func LoadGIF(path string) (*Animation, error) {
f, err := file.Open(path)
f, err := os.Open(path)
if err != nil {
return nil, err
}
@ -83,31 +83,3 @@ func LoadGIF(path string) (*Animation, error) {
}
return anim, nil
}
// Watermark 给动画所有帧添加图片水印
func (a *Animation) Watermark(mark *Canvas, pos Position, opacity float64, padding int, angle ...float64) {
for _, f := range a.Frames {
f.Watermark(mark, pos, opacity, padding, angle...)
}
}
// TileWatermark 给动画所有帧平铺图片水印
func (a *Animation) TileWatermark(mark *Canvas, opacity float64, spacing int, angle float64) {
for _, f := range a.Frames {
f.TileWatermark(mark, opacity, spacing, angle)
}
}
// TextWatermark 给动画所有帧添加文字水印
func (a *Animation) TextWatermark(text string, pos Position, style *DrawStyle, opacity float64, padding int, angle ...float64) {
for _, f := range a.Frames {
f.TextWatermark(text, pos, style, opacity, padding, angle...)
}
}
// TileTextWatermark 给动画所有帧平铺文字水印
func (a *Animation) TileTextWatermark(text string, style *DrawStyle, opacity float64, spacing int, angle float64) {
for _, f := range a.Frames {
f.TileTextWatermark(text, style, opacity, spacing, angle)
}
}

View File

@ -1,15 +1,16 @@
package vision
import (
"bytes"
"fmt"
"image"
"image/color"
"image/draw"
"image/jpeg"
_ "image/png"
"os"
"strings"
"apigo.cc/go/cast"
"apigo.cc/go/file"
"github.com/fogleman/gg"
"golang.org/x/image/font"
@ -40,19 +41,16 @@ func New(width, height int, backgroundColor ...string) *Canvas {
// Load 从文件加载图像并创建画布
func Load(path string) (*Canvas, error) {
if path == "" {
return nil, fmt.Errorf("path is empty")
}
if !file.Exists(path) {
return nil, fmt.Errorf("file not found: %s", path)
}
data, err := file.ReadBytes(path)
data, err := file.Read(path)
if err != nil {
return nil, err
}
img, _, err := image.Decode(bytes.NewReader(data))
img, _, err := image.Decode(strings.NewReader(cast.String(data)))
if err != nil {
return nil, fmt.Errorf("decode image failed: %v", err)
}
@ -72,7 +70,7 @@ func Save(c *Canvas, path string, quality ...int) error {
q = quality[0]
}
// gg 没有内置 SaveJPG 到 context我们需要手动编码
f, createErr := file.Create(path)
f, createErr := os.Create(path)
if createErr != nil {
return createErr
}

View File

@ -45,41 +45,6 @@ func GenerateCaptcha(opt *CaptchaOption) *Canvas {
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 绘制随机扭曲文本 (用于验证码)
func (c *Canvas) RandText(text string) [][4]float64 {
w, h := float64(c.Width()), float64(c.Height())

View File

@ -2,10 +2,9 @@ package vision
import (
"fmt"
"os"
"path/filepath"
"strings"
"apigo.cc/go/file"
)
// Format 定义支持的图像格式
@ -30,23 +29,23 @@ func Convert(srcPath, dstPath string, quality ...int) error {
// ConvertAll 将目录下的所有符合条件的图片转换为目标格式
func ConvertAll(srcDir, dstDir string, toExt string, quality ...int) (int, []error) {
files, err := file.ReadDir(srcDir)
files, err := os.ReadDir(srcDir)
if err != nil {
return 0, []error{err}
}
if err := file.Mkdir(dstDir); err != nil {
if err := os.MkdirAll(dstDir, 0755); err != nil {
return 0, []error{err}
}
count := 0
var errors []error
for _, f := range files {
if f.IsDir {
if f.IsDir() {
continue
}
name := f.Name
name := f.Name()
ext := strings.ToLower(filepath.Ext(name))
if ext == ".png" || ext == ".jpg" || ext == ".jpeg" {
srcPath := filepath.Join(srcDir, name)

9
go.mod
View File

@ -3,8 +3,9 @@ module apigo.cc/go/vision
go 1.25.0
require (
apigo.cc/go/cast v1.3.1
apigo.cc/go/file v1.3.1
apigo.cc/go/cast v1.3.0
apigo.cc/go/file v1.3.0
apigo.cc/go/log v1.3.0
apigo.cc/go/rand v1.3.0
github.com/boombuler/barcode v1.1.0
github.com/disintegration/imaging v1.6.2
@ -16,10 +17,12 @@ require (
)
require (
apigo.cc/go/config v1.3.0 // indirect
apigo.cc/go/encoding v1.3.0 // indirect
apigo.cc/go/id v1.3.0 // indirect
apigo.cc/go/safe v1.3.0 // indirect
apigo.cc/go/shell v1.3.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/kr/text v0.2.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect

17
go.sum
View File

@ -1,16 +1,23 @@
apigo.cc/go/cast v1.3.1 h1:Y64mit3tCtA1gnSaeaPNf9QjvwX1RA+hFc80j/yUMnI=
apigo.cc/go/cast v1.3.1/go.mod h1:lGlwImiOvHxG7buyMWhFzcdvQzmSaoKbmr7bcDfUpHk=
apigo.cc/go/cast v1.3.0 h1:ZTcLYijkqZjSWSCSpJUWMfzJYeJKbwKxquKkPrFsROQ=
apigo.cc/go/cast v1.3.0/go.mod h1:lGlwImiOvHxG7buyMWhFzcdvQzmSaoKbmr7bcDfUpHk=
apigo.cc/go/config v1.3.0 h1:TwI3bv3D+BJrAnFx+o62HQo3FarY2Ge3SCGsKchFYGg=
apigo.cc/go/config v1.3.0/go.mod h1:88lqKEBXlIExFKt1geLONVLYyM+QhRVpBe0ok3OEvjI=
apigo.cc/go/encoding v1.3.0 h1:8jqNHoZBR8vOU/BGsLFebfp1Txa1UxDRpd7YwzIFLJs=
apigo.cc/go/encoding v1.3.0/go.mod h1:kT/uUJiuAOkZ4LzUWrUtk/I0iL1D8aatvD+59bDnHBo=
apigo.cc/go/file v1.3.1 h1:qHgiJsn1K9DazWRrPoHVnXtp6hDGGsUpAE/4G1bFXqY=
apigo.cc/go/file v1.3.1/go.mod h1:pYHBlB/XwsrnWpEh7GIFpbiqobrExfiB+rEN8V2d2kY=
apigo.cc/go/file v1.3.0 h1:xG9FcY3Rv6Br83r9pq9QsIXFrplx4g8ITOkHSzfzXRg=
apigo.cc/go/file v1.3.0/go.mod h1:pYHBlB/XwsrnWpEh7GIFpbiqobrExfiB+rEN8V2d2kY=
apigo.cc/go/id v1.3.0 h1:Tr2Yj0Rl19lfwW5wBTJ407o/zgo2oVRLE20WWEgJzdE=
apigo.cc/go/id v1.3.0/go.mod h1:AFH3kMFwENfXNyijnAFWEhSF1o3y++UBPem1IUlrcxA=
apigo.cc/go/log v1.3.0 h1:61Z80WGN6SnhgxgoR8xuVYIieMdjlJKmf8JX1HXzp0Y=
apigo.cc/go/log v1.3.0/go.mod h1:dz4bSz9BnOgutkUJJZfX3uDDwsMpUxt7WF50mLK9hgE=
apigo.cc/go/rand v1.3.0 h1:k+UFAhMySwXf+dq8Om9TniZV6fm6gAE0evbrqMEdwQU=
apigo.cc/go/rand v1.3.0/go.mod h1:mZ/4Soa3bk+XvDaqPWJuUe1bfEi4eThBj1XmEAuYxsk=
apigo.cc/go/safe v1.3.0 h1:uctdAUsphT9p60Tk4oS5xPCe0NoIdOHfsYv4PNS0Rok=
apigo.cc/go/safe v1.3.0/go.mod h1:tC9X14V+qh0BqIrVg4UkXbl+2pEN+lj2ZNI8IjDB6Fs=
apigo.cc/go/shell v1.3.0 h1:hdxuYPN/7T2BuM/Ja8AjVUhbRqU/wpi8OjcJVziJ0nw=
apigo.cc/go/shell v1.3.0/go.mod h1:aNJiRWibxlA485yX3t+07IVAbrALKmxzv4oGEUC+hK4=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/flopp/go-findfont v0.1.0 h1:lPn0BymDUtJo+ZkV01VS3661HL6F4qFlkhcJN55u6mU=

View File

@ -1,79 +0,0 @@
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,
}
}

View File

@ -1,79 +0,0 @@
package vision
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
// GenerateImagePreview 生成图片预览 (WebP)
// 支持缩放并裁剪以填充指定尺寸 (Fill 模式)
func GenerateImagePreview(srcPath, outPath string, width, height int) error {
c, err := Load(srcPath)
if err != nil {
return err
}
c.Fill(width, height)
return Save(c, outPath)
}
// GenerateVideoPreview 生成视频预览 (4帧动画 WebP)
// 支持缩放并裁剪以填充指定尺寸 (Fill 模式)
func GenerateVideoPreview(videoPath, outPath string, width, height int) error {
v, err := NewVideo()
if err != nil {
return err
}
duration, err := getVideoDuration(videoPath)
if err != nil {
return err
}
times := []float64{0, duration * 0.33, duration * 0.67, duration * 0.90}
tmpDir, _ := os.MkdirTemp("", "frames")
defer os.RemoveAll(tmpDir)
// 使用 ffmpeg 的 scale 和 crop 滤镜实现 Fill 效果
// force_original_aspect_ratio=increase 确保图片至少覆盖目标尺寸
// crop=w:h 裁剪中心区域
vf := fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d", width, height, width, height)
for i, t := range times {
framePath := filepath.Join(tmpDir, fmt.Sprintf("frame_%d.png", i))
cmd := exec.Command(v.FFmpegPath, "-ss", fmt.Sprintf("%f", t), "-i", videoPath, "-frames:v", "1", "-vf", vf, framePath)
if err := cmd.Run(); err != nil {
return err
}
}
cmd := exec.Command(v.FFmpegPath, "-framerate", "1", "-i", filepath.Join(tmpDir, "frame_%d.png"),
"-c:v", "libwebp", "-lossless", "0", "-quality", "70", "-loop", "0", outPath)
return cmd.Run()
}
// GenerateAudioPreview 提取 3 分钟内的音频用于转写/预览
// 格式: Ogg Opus, 16kHz, 单声道
func GenerateAudioPreview(mediaPath, outPath string) error {
v, err := NewVideo()
if err != nil {
return err
}
// -vn: 禁用视频
// -c:a libopus: 高效音频压缩
// -ar 16000: 采样率 16k
// -t 180: 最长 180 秒
cmd := exec.Command(v.FFmpegPath, "-i", mediaPath, "-vn", "-c:a", "libopus", "-ar", "16000", "-ac", "1", "-t", "180", outPath)
return cmd.Run()
}
func getVideoDuration(videoPath string) (float64, error) {
out, err := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", videoPath).Output()
if err != nil {
return 0, err
}
var duration float64
_, err = fmt.Sscanf(string(out), "%f", &duration)
return duration, err
}

View File

@ -1,88 +0,0 @@
package vision
import (
"fmt"
"image/color"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/fogleman/gg"
)
func TestPreviewer(t *testing.T) {
// 1. 创建测试环境
tmpDir, err := os.MkdirTemp("", "vision_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
videoPath := filepath.Join(tmpDir, "test.mp4")
webPPath := filepath.Join(tmpDir, "preview.webp")
oggPath := filepath.Join(tmpDir, "preview.ogg")
// 2. 模拟生成素材 (生成 5 张纯色帧图片用于合成视频)
imgPattern := filepath.Join(tmpDir, "frame_%d.png")
for i := 0; i < 5; i++ {
dc := gg.NewContext(320, 240)
dc.SetColor(color.RGBA{uint8(i * 50), 100, 200, 255})
dc.Clear()
dc.SavePNG(fmt.Sprintf(imgPattern, i))
}
// 使用现有的 vision.Video 逻辑生成视频
v, err := NewVideo()
if err != nil {
t.Fatal(err)
}
// 这里通过 ffmpeg 拼接图片并添加一个静音音频产生视频
err = v.CreateVideoFromImages(filepath.Join(tmpDir, "frame_%d.png"), 1, videoPath)
if err != nil {
t.Skip("FFmpeg video generation failed, skipping integration test")
}
// 为测试视频添加静音音频,否则提取音频会失败
audioPath := filepath.Join(tmpDir, "silent.aac")
err = exec.Command("ffmpeg", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono", "-t", "5", "-c:a", "aac", audioPath).Run()
if err == nil {
finalVideo := filepath.Join(tmpDir, "final.mp4")
exec.Command("ffmpeg", "-i", videoPath, "-i", audioPath, "-c", "copy", finalVideo).Run()
videoPath = finalVideo
}
// 3. 测试 Preview 功能
t.Run("GenerateImagePreview", func(t *testing.T) {
imgPath := filepath.Join(tmpDir, "frame_0.png")
previewImgPath := filepath.Join(tmpDir, "img_preview.webp")
err := GenerateImagePreview(imgPath, previewImgPath, 100, 100)
if err != nil {
t.Errorf("GenerateImagePreview failed: %v", err)
}
if _, err := os.Stat(previewImgPath); os.IsNotExist(err) {
t.Error("Image preview output not created")
}
})
t.Run("GenerateVideoPreview", func(t *testing.T) {
err := GenerateVideoPreview(videoPath, webPPath, 160, 120)
if err != nil {
t.Errorf("GenerateVideoPreview failed: %v", err)
}
if _, err := os.Stat(webPPath); os.IsNotExist(err) {
t.Error("WebP output not created")
}
})
t.Run("GenerateAudioPreview", func(t *testing.T) {
err := GenerateAudioPreview(videoPath, oggPath)
if err != nil {
t.Errorf("GenerateAudioPreview failed: %v", err)
}
if _, err := os.Stat(oggPath); os.IsNotExist(err) {
t.Error("Ogg output not created")
}
})
}

View File

@ -3,9 +3,9 @@ package vision
import (
"fmt"
"image/color"
"strconv"
"strings"
"apigo.cc/go/cast"
"apigo.cc/go/rand"
)
@ -38,7 +38,8 @@ func ParseColor(hex string) color.Color {
}
func parseHex(s string) uint8 {
return cast.To[uint8]("0x" + s)
val, _ := strconv.ParseUint(s, 16, 8)
return uint8(val)
}
// RandColor 生成随机颜色 hex 字符串

View File

@ -37,21 +37,6 @@ func (v *Video) ExtractFrame(videoPath string, offsetSeconds float64) (*Canvas,
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 从一系列图片创建视频
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)

View File

@ -1,8 +1,6 @@
package vision
import (
"image"
"image/color"
"os"
"testing"
)
@ -65,36 +63,6 @@ func TestPHash(t *testing.T) {
t.Logf("pHash distance: %d", dist)
}
func TestTileWatermark(t *testing.T) {
c := New(400, 300, "#FFFFFF")
c.Circle(200, 150, 50, &DrawStyle{FillColor: "#0000FF"})
c.TileTextWatermark("CONFIDENTIAL", &DrawStyle{FillColor: "#FF0000"}, 0.2, 50, -0.785) // 45度
err := Save(c, "test_tile_watermark.png")
if err != nil {
t.Fatalf("save tile watermark test failed: %v", err)
}
defer os.Remove("test_tile_watermark.png")
}
func TestAnimationWatermark(t *testing.T) {
anim := NewAnimation()
for i := 0; i < 5; i++ {
c := New(100, 100, "#FFFFFF")
c.Circle(float64(i*20), 50, 10, &DrawStyle{FillColor: "#00FF00"})
anim.AddFrame(c, 10)
}
anim.TextWatermark("GO", Center, &DrawStyle{FillColor: "#000000"}, 0.5, 0)
err := anim.SaveGIF("test_anim_watermark.gif", 0)
if err != nil {
t.Fatalf("save anim watermark failed: %v", err)
}
defer os.Remove("test_anim_watermark.gif")
}
func TestQRCode(t *testing.T) {
content := "https://apigo.cc"
c, err := GenerateQRCode(content, 200)
@ -111,42 +79,6 @@ 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) {
content := "12345678"
c, err := GenerateBarcode(content, 200, 50)
@ -162,34 +94,3 @@ func TestBarcode(t *testing.T) {
t.Errorf("expected %s, got %s", content, decoded)
}
}
func BenchmarkWarpPerspective(b *testing.B) {
c := New(1000, 1000, "#FFFFFF")
c.Circle(500, 500, 300, &DrawStyle{FillColor: "#FF0000"})
srcPoints := [4]image.Point{
{100, 100}, {900, 150}, {850, 850}, {150, 800},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
c.WarpPerspective(srcPoints, 500, 500)
}
}
func BenchmarkPHash(b *testing.B) {
c := New(500, 500, "#FFFFFF")
c.Circle(250, 250, 100, &DrawStyle{FillColor: "#000000"})
img := c.Image()
b.ResetTimer()
for i := 0; i < b.N; i++ {
PHash(img)
}
}
func BenchmarkExtractPalette(b *testing.B) {
c := New(500, 500, "#FFFFFF")
c.RandBG(5)
b.ResetTimer()
for i := 0; i < b.N; i++ {
c.ExtractPalette(10)
}
}

View File

@ -1,139 +0,0 @@
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: 边距
// angle: 旋转角度 (弧度)
func (c *Canvas) Watermark(mark *Canvas, pos Position, opacity float64, padding int, angle ...float64) {
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
}
c.dc.Push()
if len(angle) > 0 && angle[0] != 0 {
c.dc.RotateAbout(angle[0], float64(x+mw/2), float64(y+mh/2))
}
// 处理透明度
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)
}
c.dc.Pop()
}
// TileWatermark 平铺图片水印
// spacing: 间距
// angle: 旋转角度 (弧度)
func (c *Canvas) TileWatermark(mark *Canvas, opacity float64, spacing int, angle float64) {
if mark == nil || opacity < 0.01 {
return
}
mw, mh := mark.Width(), mark.Height()
w, h := c.Width(), c.Height()
stepX := mw + spacing
stepY := mh + spacing
for y := -mh; y < h+mh; y += stepY {
for x := -mw; x < w+mw; x += stepX {
c.dc.Push()
c.dc.RotateAbout(angle, float64(x+mw/2), float64(y+mh/2))
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)
}
c.dc.Pop()
}
}
}
// TextWatermark 给画布添加文字水印
func (c *Canvas) TextWatermark(text string, pos Position, style *DrawStyle, opacity float64, padding int, angle ...float64) {
// 创建一个临时画布来渲染文字,然后调用 Watermark
c.dc.Push()
tw, th := c.dc.MeasureString(text)
temp := New(int(tw)+4, int(th)+4)
if style != nil && style.FillColor != "" {
temp.dc.SetColor(ParseColor(style.FillColor))
} else {
temp.dc.SetColor(color.Black)
}
temp.dc.DrawString(text, 2, th+2)
ang := 0.0
if len(angle) > 0 {
ang = angle[0]
}
c.Watermark(temp, pos, opacity, padding, ang)
c.dc.Pop()
}
// TileTextWatermark 平铺文字水印
func (c *Canvas) TileTextWatermark(text string, style *DrawStyle, opacity float64, spacing int, angle float64) {
c.dc.Push()
tw, th := c.dc.MeasureString(text)
temp := New(int(tw)+4, int(th)+4)
if style != nil && style.FillColor != "" {
temp.dc.SetColor(ParseColor(style.FillColor))
} else {
temp.dc.SetColor(color.Black)
}
temp.dc.DrawString(text, 2, th+2)
c.TileWatermark(temp, opacity, spacing, angle)
c.dc.Pop()
}