diff --git a/go.mod b/go.mod index da8f9e0..bfb72a0 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/lukaszraczylo/captcha-solver-mcp 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 ( github.com/google/jsonschema-go v0.3.0 // indirect diff --git a/go.sum b/go.sum index 3bc80f3..5348995 100644 --- a/go.sum +++ b/go.sum @@ -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/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= +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/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= diff --git a/internal/imageutil/imageutil_test.go b/internal/imageutil/imageutil_test.go index 4051d58..335d062 100644 --- a/internal/imageutil/imageutil_test.go +++ b/internal/imageutil/imageutil_test.go @@ -1,12 +1,34 @@ package imageutil import ( + "bytes" "encoding/base64" + "image" + _ "image/png" // register PNG decoder for test helpers + "image/color" + "image/png" "os" "path/filepath" "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) { raw := []byte{0x89, 0x50, 0x4e, 0x47} // PNG magic prefix 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) { pts := GridCentroids(300, 300, 3, 3) if len(pts) != 9 { diff --git a/internal/imageutil/preprocess.go b/internal/imageutil/preprocess.go new file mode 100644 index 0000000..f69e575 --- /dev/null +++ b/internal/imageutil/preprocess.go @@ -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 +} +