Files
gen2d/backend/pkg/splitsprite/splitsprite.go
T

300 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}