diff --git a/backend/pkg/splitsprite/splitsprite.go b/backend/pkg/splitsprite/splitsprite.go new file mode 100644 index 0000000..d63c5a4 --- /dev/null +++ b/backend/pkg/splitsprite/splitsprite.go @@ -0,0 +1,299 @@ +// Package splitsprite provides PNG sprite sheet splitting utilities. +// +// Pipeline: green screen removal → projection-based gap detection → +// split into tiles → filter out low-fill tiles → trim transparent edges. +package splitsprite + +import ( + "fmt" + "image" + "image/color" + "image/draw" + "math" + "sort" +) + +// Options configures the sprite sheet splitting pipeline. +type Options struct { + // GreenScreen enables green background removal. + GreenScreen bool + // GreenTolerance controls how aggressively green pixels are removed (0–1, default 0.2). + GreenTolerance float64 + + // GapThreshold is the max fraction of non-transparent pixels a row/column + // can have to be considered a gap (0–1, default 0.03). + GapThreshold float64 + // MinGapWidth is the minimum width in pixels a gap must have. + MinGapWidth int + + // MinFillRatio is the minimum fraction of non-transparent pixels a tile + // must have to be kept (0–1, default 0.3). + MinFillRatio float64 + + // Trim removes transparent borders from output tiles. + Trim bool + // OutW / OutH specify the output tile size (0 = keep original). + OutW, OutH int +} + +// DefaultOptions returns sensible default splitting options. +func DefaultOptions() *Options { + return &Options{ + GreenScreen: true, + GreenTolerance: 0.2, + GapThreshold: 0.03, + MinGapWidth: 2, + MinFillRatio: 0.3, + Trim: true, + } +} + +// Process splits a sprite sheet image into individual cleaned tile images. +// It runs the full pipeline: green screen removal → split → trim → resize. +func Process(img image.Image, opts *Options) ([]image.Image, error) { + if opts == nil { + opts = DefaultOptions() + } + + src := toRGBA(img) + + if opts.GreenScreen { + src = removeGreenScreen(src, opts.GreenTolerance) + } + + tiles := projectionSplit(src, opts.GapThreshold, opts.MinGapWidth, opts.MinFillRatio) + if len(tiles) == 0 { + return nil, fmt.Errorf("no tiles detected — try lowering GapThreshold or adjusting GreenTolerance") + } + + results := make([]image.Image, len(tiles)) + for i, t := range tiles { + sub := image.NewRGBA(image.Rect(0, 0, t.w, t.h)) + draw.Draw(sub, sub.Bounds(), src, image.Point{t.x, t.y}, draw.Src) + + if opts.Trim { + sub = trimAlpha(sub) + } + if opts.OutW > 0 && opts.OutH > 0 { + sub = resize(sub, opts.OutW, opts.OutH) + } + results[i] = sub + } + return results, nil +} + +// RemoveGreenScreen removes green-dominant background pixels, making them transparent. +func RemoveGreenScreen(img image.Image, tol float64) image.Image { + return removeGreenScreen(toRGBA(img), tol) +} + +// TrimAlpha removes fully transparent borders from an image. +func TrimAlpha(img image.Image) image.Image { + return trimAlpha(toRGBA(img)) +} + +// Resize resizes an image using nearest-neighbor interpolation. +func Resize(img image.Image, w, h int) image.Image { + return resize(toRGBA(img), w, h) +} + +// ============================ +// internal +// ============================ + +type tile struct { + x, y, w, h int +} + +func removeGreenScreen(rgba *image.RGBA, tol float64) *image.RGBA { + b := rgba.Bounds() + dst := image.NewRGBA(b) + absTol := tol * 255 + + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + r16, g16, bl16, a16 := rgba.At(x, y).RGBA() + if a16 == 0 { + continue + } + r, g, bl := float64(r16>>8), float64(g16>>8), float64(bl16>>8) + gDominance := g - (r+bl)/2 + if gDominance > absTol { + alpha := 1.0 - math.Min(gDominance/(absTol*2), 1.0) + dst.SetRGBA(x, y, color.RGBA{ + R: uint8(r), G: uint8(g), B: uint8(bl), + A: uint8(alpha * 255), + }) + } else { + dst.Set(x, y, rgba.At(x, y)) + } + } + } + return dst +} + +func projectionSplit(rgba *image.RGBA, gapThreshold float64, minGap int, minFill float64) []tile { + bounds := rgba.Bounds() + W, H := bounds.Dx(), bounds.Dy() + + rowRatio := make([]float64, H) + colRatio := make([]float64, W) + for y := 0; y < H; y++ { + n := 0 + for x := 0; x < W; x++ { + if alphaAt(rgba, x, y) > 0 { + n++ + } + } + rowRatio[y] = float64(n) / float64(W) + } + for x := 0; x < W; x++ { + n := 0 + for y := 0; y < H; y++ { + if alphaAt(rgba, x, y) > 0 { + n++ + } + } + colRatio[x] = float64(n) / float64(H) + } + + rowCuts := findCuts(rowRatio, gapThreshold, minGap) + colCuts := findCuts(colRatio, gapThreshold, minGap) + + if len(rowCuts) < 2 || len(colCuts) < 2 { + return nil + } + + var tiles []tile + for ri := 0; ri < len(rowCuts)-1; ri++ { + for ci := 0; ci < len(colCuts)-1; ci++ { + x := colCuts[ci] + y := rowCuts[ri] + w := colCuts[ci+1] - x + h := rowCuts[ri+1] - y + if tileFillRatio(rgba, x, y, w, h) >= minFill { + tiles = append(tiles, tile{x: x, y: y, w: w, h: h}) + } + } + } + return tiles +} + +func tileFillRatio(rgba *image.RGBA, x0, y0, w, h int) float64 { + total := w * h + if total == 0 { + return 0 + } + n := 0 + for y := y0; y < y0+h; y++ { + for x := x0; x < x0+w; x++ { + if alphaAt(rgba, x, y) > 0 { + n++ + } + } + } + return float64(n) / float64(total) +} + +func findCuts(ratios []float64, threshold float64, minGap int) []int { + n := len(ratios) + isGap := make([]bool, n) + for i, r := range ratios { + isGap[i] = r < threshold + } + + type segment struct{ start, end int } + var gaps []segment + i := 0 + for i < n { + if isGap[i] { + start := i + for i < n && isGap[i] { + i++ + } + if i-start >= minGap { + gaps = append(gaps, segment{start, i}) + } + } else { + i++ + } + } + + if len(gaps) == 0 { + return []int{0, n} + } + + cuts := []int{0} + for _, seg := range gaps { + cuts = append(cuts, seg.start+(seg.end-seg.start)/2) + } + cuts = append(cuts, n) + sort.Ints(cuts) + + dedup := cuts[:1] + for j := 1; j < len(cuts); j++ { + if cuts[j] != dedup[len(dedup)-1] { + dedup = append(dedup, cuts[j]) + } + } + return dedup +} + +func alphaAt(rgba *image.RGBA, x, y int) uint8 { + return rgba.Pix[rgba.PixOffset(x, y)+3] +} + +func trimAlpha(rgba *image.RGBA) *image.RGBA { + b := rgba.Bounds() + minX, minY := b.Max.X, b.Max.Y + maxX, maxY := b.Min.X, b.Min.Y + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + if alphaAt(rgba, x-b.Min.X, y-b.Min.Y) > 0 { + if x < minX { + minX = x + } + if x > maxX { + maxX = x + } + if y < minY { + minY = y + } + if y > maxY { + maxY = y + } + } + } + } + w := maxX - minX + 1 + h := maxY - minY + 1 + if w <= 0 || h <= 0 { + return rgba + } + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + draw.Draw(dst, dst.Bounds(), rgba, image.Point{minX, minY}, draw.Src) + return dst +} + +func resize(rgba *image.RGBA, w, h int) *image.RGBA { + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + sw, sh := rgba.Bounds().Dx(), rgba.Bounds().Dy() + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + sx := x * sw / w + sy := y * sh / h + dst.Set(x, y, rgba.At(sx, sy)) + } + } + return dst +} + +func toRGBA(src image.Image) *image.RGBA { + if rgba, ok := src.(*image.RGBA); ok { + return rgba + } + b := src.Bounds() + rgba := image.NewRGBA(b) + draw.Draw(rgba, b, src, b.Min, draw.Src) + return rgba +}