Files
gen2d/backend/pkg/splitsprite/splitsprite.go
T
Gmarker689 f3dfcd3b0b feat: splitsprite 增加白底移除、固定网格拆分与帧居中功能
- 新增 RemoveWhiteBg:纯白色背景(#FFFFFF)像素半透明化
- 新增 fixedGridSplit:GridRows×GridCols 固定网格拆分
- 新增 CenterAlign:帧内容居中对齐,确保人物不跳帧
- Options 新增 WhiteBg/GridRows/GridCols/GridPadding/CenterAlign
- 默认模式从绿幕切换为白底
- 新增 tools/test_prompt_to_gif.go 端到端测试脚本
- .gitignore 忽略 test_output 目录
2026-05-25 16:48:48 +08:00

482 lines
12 KiB
Go
Executable File
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: white/green background 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 {
// WhiteBg enables white background removal.
WhiteBg bool
// WhiteThreshold is the max distance from pure white (0–255, default 40).
WhiteThreshold uint8
// GreenScreen enables green background removal.
GreenScreen bool
// GreenTolerance controls how aggressively green pixels are removed (0–1, default 0.2).
GreenTolerance float64
// GridRows / GridCols enable fixed-grid splitting (overrides projection detection).
// When >0, the image is divided equally into Rows×Cols cells.
GridRows int
GridCols int
// GridPadding is the gap between cells in pixels (default 2).
GridPadding int
// 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
// CenterAlign centers content across all frames so characters stay in place.
// All output frames get the same dimensions with content centered.
CenterAlign bool
// OutW / OutH specify the output tile size (0 = keep original).
OutW, OutH int
}
// DefaultOptions returns sensible default splitting options (white background mode).
func DefaultOptions() *Options {
return &Options{
WhiteBg: true,
WhiteThreshold: 40,
GapThreshold: 0.03,
MinGapWidth: 2,
MinFillRatio: 0.3,
Trim: true,
}
}
// DefaultGreenOptions returns options tuned for green screen sprite sheets.
func DefaultGreenOptions() *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: background removal → split → trim → resize.
// When GridRows/GridCols > 0, fixed-grid splitting is used instead of projection detection.
func Process(img image.Image, opts *Options) ([]image.Image, error) {
if opts == nil {
opts = DefaultOptions()
}
src := toRGBA(img)
if opts.WhiteBg {
src = removeWhiteBg(src, opts.WhiteThreshold)
} else if opts.GreenScreen {
src = removeGreenScreen(src, opts.GreenTolerance)
}
var tiles []tile
if opts.GridRows > 0 && opts.GridCols > 0 {
tiles = fixedGridSplit(src, opts.GridRows, opts.GridCols, opts.GridPadding, opts.MinFillRatio)
} else {
tiles = projectionSplit(src, opts.GapThreshold, opts.MinGapWidth, opts.MinFillRatio)
}
if len(tiles) == 0 {
return nil, fmt.Errorf("no tiles detected — try lowering GapThreshold or setting GridRows/GridCols")
}
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
}
if opts.CenterAlign && len(results) > 1 {
results = alignCenter(results)
}
return results, nil
}
// RemoveWhiteBg removes near-white background pixels, making them transparent.
func RemoveWhiteBg(img image.Image, threshold uint8) image.Image {
return removeWhiteBg(toRGBA(img), threshold)
}
// CenterFrames centers the content of each frame within a uniform canvas so
// characters stay in place across frames.
func CenterFrames(frames []image.Image) []image.Image {
return alignCenter(frames)
}
// alignCenter finds the content bounding box per frame, computes the max
// dimensions, then pads each frame so content is centered uniformly.
func alignCenter(frames []image.Image) []image.Image {
type contentBox struct {
minX, minY, maxX, maxY int
}
boxes := make([]contentBox, len(frames))
maxW, maxH := 0, 0
for i, f := range frames {
b := f.Bounds()
minX, minY := b.Max.X, b.Max.Y
maxX, maxY := b.Min.X, b.Min.Y
hasContent := false
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
_, _, _, a := f.At(x, y).RGBA()
if a > 0 {
hasContent = true
if x < minX {
minX = x
}
if x > maxX {
maxX = x
}
if y < minY {
minY = y
}
if y > maxY {
maxY = y
}
}
}
}
if !hasContent {
boxes[i] = contentBox{0, 0, b.Dx(), b.Dy()}
} else {
boxes[i] = contentBox{minX, minY, maxX, maxY}
}
w := boxes[i].maxX - boxes[i].minX + 1
h := boxes[i].maxY - boxes[i].minY + 1
if w > maxW {
maxW = w
}
if h > maxH {
maxH = h
}
}
// Pad by 10% to avoid edge cropping
maxW = maxW * 11 / 10
maxH = maxH * 11 / 10
out := make([]image.Image, len(frames))
for i, f := range frames {
cb := boxes[i]
cw := cb.maxX - cb.minX + 1
ch := cb.maxY - cb.minY + 1
ox := (maxW - cw) / 2
oy := (maxH - ch) / 2
canvas := image.NewRGBA(image.Rect(0, 0, maxW, maxH))
draw.Draw(canvas,
image.Rect(ox, oy, ox+cw, oy+ch),
f,
image.Point{cb.minX, cb.minY},
draw.Src,
)
out[i] = canvas
}
return out
}
// 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
}
// removeWhiteBg removes pixels close to pure white (R,G,B all above threshold).
func removeWhiteBg(rgba *image.RGBA, threshold uint8) *image.RGBA {
if threshold == 0 {
threshold = 40
}
b := rgba.Bounds()
dst := image.NewRGBA(b)
draw.Draw(dst, b, rgba, b.Min, draw.Src)
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
r, g, bl, a := rgba.At(x, y).RGBA()
if a == 0 {
continue
}
r8, g8, b8 := uint8(r>>8), uint8(g>>8), uint8(bl>>8)
// Pixel is "white" when all channels are near 255
if int(255-r8) < int(threshold) && int(255-g8) < int(threshold) && int(255-b8) < int(threshold) {
// Calculate alpha: closer to white = more transparent
dist := max(int(255-r8), max(int(255-g8), int(255-b8)))
alpha := float64(dist) / float64(threshold)
dst.SetRGBA(x, y, color.RGBA{R: r8, G: g8, B: b8, A: uint8(alpha * 255)})
}
}
}
return dst
}
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
}
// fixedGridSplit divides the image into Rows×Cols equally-sized cells,
// accounting for a fixed padding between cells.
func fixedGridSplit(rgba *image.RGBA, rows, cols, padding int, minFill float64) []tile {
b := rgba.Bounds()
W, H := b.Dx(), b.Dy()
if padding < 0 {
padding = 0
}
totalPadW := padding * (cols + 1)
totalPadH := padding * (rows + 1)
cellW := (W - totalPadW) / cols
cellH := (H - totalPadH) / rows
if cellW <= 0 || cellH <= 0 {
return nil
}
var tiles []tile
for r := 0; r < rows; r++ {
for c := 0; c < cols; c++ {
x := padding + c*(cellW+padding)
y := padding + r*(cellH+padding)
if tileFillRatio(rgba, x, y, cellW, cellH) >= minFill {
tiles = append(tiles, tile{x: x, y: y, w: cellW, h: cellH})
}
}
}
return tiles
}
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
}