mirror of
https://github.com/lukaszraczylo/mcp-captcharesolver.git
synced 2026-10-19 01:27:38 +00:00
feat(imageutil): add upscale and numbered grid-overlay preprocessing
This commit is contained in:
@@ -2,7 +2,10 @@ module github.com/lukaszraczylo/captcha-solver-mcp
|
|||||||
|
|
||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
require github.com/modelcontextprotocol/go-sdk v1.2.0
|
require (
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.2.0
|
||||||
|
golang.org/x/image v0.41.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/google/jsonschema-go v0.3.0 // indirect
|
github.com/google/jsonschema-go v0.3.0 // indirect
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzo
|
|||||||
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
|
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
|
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||||
|
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||||
|
|||||||
@@ -1,12 +1,34 @@
|
|||||||
package imageutil
|
package imageutil
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"image"
|
||||||
|
_ "image/png" // register PNG decoder for test helpers
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// makePNG creates an in-memory PNG of the given size filled with a solid color.
|
||||||
|
func makePNG(t *testing.T, w, h int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
c := color.RGBA{R: 100, G: 149, B: 237, A: 255}
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
img.SetRGBA(x, y, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
t.Fatalf("makePNG encode: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
func TestDecode(t *testing.T) {
|
func TestDecode(t *testing.T) {
|
||||||
raw := []byte{0x89, 0x50, 0x4e, 0x47} // PNG magic prefix
|
raw := []byte{0x89, 0x50, 0x4e, 0x47} // PNG magic prefix
|
||||||
b64 := base64.StdEncoding.EncodeToString(raw)
|
b64 := base64.StdEncoding.EncodeToString(raw)
|
||||||
@@ -36,6 +58,100 @@ func TestDecode(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpscale(t *testing.T) {
|
||||||
|
const srcW, srcH = 30, 30
|
||||||
|
|
||||||
|
t.Run("factor 3 doubles dims", func(t *testing.T) {
|
||||||
|
src := makePNG(t, srcW, srcH)
|
||||||
|
out, mt, err := Upscale(src, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if mt != "image/png" {
|
||||||
|
t.Fatalf("want image/png, got %q", mt)
|
||||||
|
}
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(out))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("result not decodable: %v", err)
|
||||||
|
}
|
||||||
|
b := img.Bounds()
|
||||||
|
if b.Dx() != srcW*3 || b.Dy() != srcH*3 {
|
||||||
|
t.Fatalf("want %dx%d, got %dx%d", srcW*3, srcH*3, b.Dx(), b.Dy())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("factor 10 clamps to 6", func(t *testing.T) {
|
||||||
|
src := makePNG(t, srcW, srcH)
|
||||||
|
out, _, err := Upscale(src, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(out))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("result not decodable: %v", err)
|
||||||
|
}
|
||||||
|
b := img.Bounds()
|
||||||
|
if b.Dx() != srcW*6 || b.Dy() != srcH*6 {
|
||||||
|
t.Fatalf("want %dx%d (clamped to 6), got %dx%d", srcW*6, srcH*6, b.Dx(), b.Dy())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("factor 1 unchanged dims", func(t *testing.T) {
|
||||||
|
src := makePNG(t, srcW, srcH)
|
||||||
|
out, mt, err := Upscale(src, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if mt != "image/png" {
|
||||||
|
t.Fatalf("want image/png, got %q", mt)
|
||||||
|
}
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(out))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("result not decodable: %v", err)
|
||||||
|
}
|
||||||
|
b := img.Bounds()
|
||||||
|
if b.Dx() != srcW || b.Dy() != srcH {
|
||||||
|
t.Fatalf("want %dx%d, got %dx%d", srcW, srcH, b.Dx(), b.Dy())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnnotateGrid(t *testing.T) {
|
||||||
|
const size = 90
|
||||||
|
|
||||||
|
t.Run("3x3 grid same dims", func(t *testing.T) {
|
||||||
|
src := makePNG(t, size, size)
|
||||||
|
out, err := AnnotateGrid(src, 3, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(out))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("result not decodable: %v", err)
|
||||||
|
}
|
||||||
|
b := img.Bounds()
|
||||||
|
if b.Dx() != size || b.Dy() != size {
|
||||||
|
t.Fatalf("want %dx%d, got %dx%d", size, size, b.Dx(), b.Dy())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rows=0 returns valid image", func(t *testing.T) {
|
||||||
|
src := makePNG(t, size, size)
|
||||||
|
out, err := AnnotateGrid(src, 0, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(out))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("result not decodable: %v", err)
|
||||||
|
}
|
||||||
|
b := img.Bounds()
|
||||||
|
if b.Dx() != size || b.Dy() != size {
|
||||||
|
t.Fatalf("want %dx%d, got %dx%d", size, size, b.Dx(), b.Dy())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestGridCentroids(t *testing.T) {
|
func TestGridCentroids(t *testing.T) {
|
||||||
pts := GridCentroids(300, 300, 3, 3)
|
pts := GridCentroids(300, 300, 3, 3)
|
||||||
if len(pts) != 9 {
|
if len(pts) != 9 {
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package imageutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
_ "image/jpeg" // register JPEG decoder
|
||||||
|
"image/png"
|
||||||
|
|
||||||
|
xdraw "golang.org/x/image/draw"
|
||||||
|
"golang.org/x/image/font"
|
||||||
|
"golang.org/x/image/font/basicfont"
|
||||||
|
"golang.org/x/image/math/fixed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Upscale decodes a PNG/JPEG image, scales it by factor (clamped to 1..6)
|
||||||
|
// using high-quality resampling, and returns a PNG re-encode plus "image/png".
|
||||||
|
func Upscale(data []byte, factor int) ([]byte, string, error) {
|
||||||
|
if factor < 1 {
|
||||||
|
factor = 1
|
||||||
|
}
|
||||||
|
if factor > 6 {
|
||||||
|
factor = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
src, _, err := image.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("upscale: decode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds := src.Bounds()
|
||||||
|
newW := bounds.Dx() * factor
|
||||||
|
newH := bounds.Dy() * factor
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
|
||||||
|
|
||||||
|
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), xdraw.Over, nil)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, dst); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("upscale: encode: %w", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), "image/png", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnnotateGrid decodes the image and overlays a rows x cols grid: thin lines
|
||||||
|
// on cell boundaries and the 0-based row-major cell index drawn in the
|
||||||
|
// top-left of each cell, returning a PNG. Helps a vision model reference cells
|
||||||
|
// unambiguously. If rows<=0 or cols<=0, returns the original re-encoded PNG.
|
||||||
|
func AnnotateGrid(data []byte, rows, cols int) ([]byte, error) {
|
||||||
|
src, _, err := image.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("annotategrid: decode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds := src.Bounds()
|
||||||
|
dst := image.NewRGBA(bounds)
|
||||||
|
draw.Draw(dst, bounds, src, bounds.Min, draw.Src)
|
||||||
|
|
||||||
|
if rows <= 0 || cols <= 0 {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, dst); err != nil {
|
||||||
|
return nil, fmt.Errorf("annotategrid: encode: %w", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
w := bounds.Dx()
|
||||||
|
h := bounds.Dy()
|
||||||
|
lineColor := color.RGBA{R: 220, G: 30, B: 30, A: 160}
|
||||||
|
|
||||||
|
// Draw vertical grid lines.
|
||||||
|
for c := 1; c < cols; c++ {
|
||||||
|
x := c * w / cols
|
||||||
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||||
|
dst.SetRGBA(x, y, lineColor)
|
||||||
|
if x+1 < bounds.Max.X {
|
||||||
|
dst.SetRGBA(x+1, y, lineColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw horizontal grid lines.
|
||||||
|
for r := 1; r < rows; r++ {
|
||||||
|
y := r * h / rows
|
||||||
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||||
|
dst.SetRGBA(x, y, lineColor)
|
||||||
|
if y+1 < bounds.Max.Y {
|
||||||
|
dst.SetRGBA(x+1, y+1, lineColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw cell labels.
|
||||||
|
face := basicfont.Face7x13
|
||||||
|
ascent := face.Ascent // plain int pixels
|
||||||
|
descent := face.Descent // plain int pixels
|
||||||
|
advance := face.Advance // plain int pixels per glyph
|
||||||
|
labelColor := color.RGBA{R: 255, G: 255, B: 255, A: 255}
|
||||||
|
bgColor := color.RGBA{R: 0, G: 0, B: 0, A: 180}
|
||||||
|
|
||||||
|
for r := 0; r < rows; r++ {
|
||||||
|
for c := 0; c < cols; c++ {
|
||||||
|
idx := r*cols + c
|
||||||
|
label := fmt.Sprintf("%d", idx)
|
||||||
|
|
||||||
|
cellX := c * w / cols
|
||||||
|
cellY := r * h / rows
|
||||||
|
|
||||||
|
// Label position: 2px padding from cell top-left.
|
||||||
|
px := cellX + 2
|
||||||
|
py := cellY + ascent + 2
|
||||||
|
|
||||||
|
labelW := len(label) * advance
|
||||||
|
|
||||||
|
// Draw dark background rect behind label.
|
||||||
|
bgRect := image.Rect(px-1, py-ascent-1, px+labelW+1, py+descent+1)
|
||||||
|
draw.Draw(dst, bgRect, &image.Uniform{bgColor}, image.Point{}, draw.Over)
|
||||||
|
|
||||||
|
// Draw label text.
|
||||||
|
d := &font.Drawer{
|
||||||
|
Dst: dst,
|
||||||
|
Src: image.NewUniform(labelColor),
|
||||||
|
Face: face,
|
||||||
|
Dot: fixed.P(px, py),
|
||||||
|
}
|
||||||
|
d.DrawString(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, dst); err != nil {
|
||||||
|
return nil, fmt.Errorf("annotategrid: encode: %w", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user