diff --git a/.gitignore b/.gitignore index 90d4f7d..c383bd1 100755 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ backend/main # Generated output generation/ backend/test_output/ +backend/test_prompt_to_gif diff --git a/backend/internal/service/prompt_agent.go b/backend/internal/service/prompt_agent.go index 19a5270..379cbbf 100755 --- a/backend/internal/service/prompt_agent.go +++ b/backend/internal/service/prompt_agent.go @@ -91,19 +91,20 @@ func buildMetaPrompt(in PromptAgentInput) string { sb.WriteString("2. 使用专业术语,描述具体、可执行\n") sb.WriteString("3. 风格一致,适合游戏资产管线\n") sb.WriteString("4. 根据用户标签和描述,识别素材布局模式,在【技术】段明确标注格式:\n") + sb.WriteString(" *** 关键:帧间间隙必须留足8-16px纯白色(#FFFFFF)空白区域,间隙内不得有任何像素,确保投影法能可靠检测到间隙 ***\n") switch in.AssetType { case "sprite": sb.WriteString(" - 单个精灵(默认):独立PNG,纯白色背景(#FFFFFF),描述为单个角色立绘/道具图标\n") - sb.WriteString(" - 精灵表(spritesheet):角色/道具按行列等距网格排列,纯白色背景(#FFFFFF),帧间固定间距(2-4px),标注行列数,便于脚本一键拆分\n") + sb.WriteString(" - 精灵表(spritesheet):角色/道具按行列等距网格排列,纯白色背景(#FFFFFF),帧间留8-16px纯白间隙(无像素残留),标注行列数\n") case "background": sb.WriteString(" - 独立场景(默认):单张完整背景图,层次分明\n") - sb.WriteString(" - 场景瓦片集(tileset):地形/建筑元件按规则网格排列,纯白色背景(#FFFFFF),元件间固定间距,标注行列数与瓦片尺寸,确保无缝拼接\n") + sb.WriteString(" - 场景瓦片集(tileset):地形/建筑元件按规则网格排列,纯白色背景(#FFFFFF),元件间留8-16px纯白间隙,标注行列数与瓦片尺寸\n") case "ui": sb.WriteString(" - 独立UI元素(默认):单个按钮/面板/图标,纯白色背景(#FFFFFF),独立PNG\n") - sb.WriteString(" - UI瓦片集(tileset):UI元件按规则网格排列,纯白色背景(#FFFFFF),元件间固定间距,标注行列数,支持九宫格缩放,便于脚本一键拆分\n") + sb.WriteString(" - UI瓦片集(tileset):UI元件按规则网格排列,纯白色背景(#FFFFFF),元件间留8-16px纯白间隙,标注行列数,支持九宫格缩放\n") case "animation": sb.WriteString(" - 帧序列(默认):连续动画帧,独立帧文件或帧条带,纯白色背景(#FFFFFF)\n") - sb.WriteString(" - 动画精灵表(spritesheet):动画帧按行列等距网格排列,纯白色背景(#FFFFFF),帧间固定间距,标注行列数与方向数,便于脚本一键拆分\n") + sb.WriteString(" - 动画精灵表(spritesheet):动画帧按行列等距网格排列,纯白色背景(#FFFFFF),帧间留8-16px纯白间隙(无像素残留),标注行列数与方向数\n") } sb.WriteString("\n\n") @@ -360,22 +361,22 @@ func buildTechNotes(assetType string, isSheet bool) string { switch assetType { case "sprite": if isSheet { - return "输出格式: spritesheet;行列间隔完全相等、可脚本一键拆分对齐;纯白色背景(#FFFFFF,无渐变无噪点);帧间固定间距(2-4px);标注行列数" + return "输出格式: spritesheet;帧间留8-16px纯白间隙(间隙内无任何像素),行/列间隙完全相等;纯白色背景(#FFFFFF,无渐变无噪点);标注行列数" } return "输出格式: 独立PNG;纯白色背景(#FFFFFF);尺寸: 按角色比例适配" case "background": if isSheet { - return "输出格式: 场景瓦片集(tileset);规则网格排列;纯白色背景(#FFFFFF);瓦片间固定间距;标注行列数与瓦片尺寸;确保无缝拼接" + return "输出格式: 场景瓦片集(tileset);规则网格排列;纯白色背景(#FFFFFF);元件间留8-16px纯白间隙;标注行列数与瓦片尺寸;确保无缝拼接" } return "输出格式: 独立PNG;分辨率: 1920x1080;层次分明的前中后景" case "ui": if isSheet { - return "输出格式: UI瓦片集(tileset);规则网格排列;纯白色背景(#FFFFFF);元件间固定间距;标注行列数;支持九宫格缩放;可脚本一键拆分" + return "输出格式: UI瓦片集(tileset);规则网格排列;纯白色背景(#FFFFFF);元件间留8-16px纯白间隙;标注行列数;支持九宫格缩放;可脚本一键拆分" } return "输出格式: 独立PNG素材;纯白色背景(#FFFFFF);分辨率: 按元素适配;支持九宫格缩放" case "animation": if isSheet { - return "输出格式: 动画精灵表(spritesheet);行列间隔相等;纯白色背景(#FFFFFF,无渐变无噪点);帧间固定间距;标注行列数与方向数;可脚本一键拆分" + return "输出格式: 动画精灵表(spritesheet);帧间留8-16px纯白间隙(间隙内无任何像素),行/列间隙相等;纯白色背景(#FFFFFF,无渐变无噪点);标注行列数与方向数" } return "输出格式: 帧序列或帧条带;独立帧文件;纯白色背景(#FFFFFF);建议4方向x4帧" default: diff --git a/backend/pkg/gifmaker/gifmaker.go b/backend/pkg/gifmaker/gifmaker.go new file mode 100644 index 0000000..7eebccc --- /dev/null +++ b/backend/pkg/gifmaker/gifmaker.go @@ -0,0 +1,128 @@ +// Package gifmaker encodes sprite animation frames into a GIF preview. +// +// Features: +// - Unified canvas: all frames normalized to the same dimensions +// - Transparent background: palette index 0 = fully transparent +// - DisposalBackground: each frame clears the previous one, no ghosting +// +// Pipeline integration: +// +// frames, _ := splitsprite.Process(img, splitsprite.DefaultOptions()) +// gifmaker.Save("preview.gif", frames, nil) +package gifmaker + +import ( + "fmt" + "image" + "image/color" + "image/gif" + "io" + "os" +) + +// Options configures GIF generation. +type Options struct { + // Delay is the frame delay in 1/100s (default 10). + Delay int + // MaxColors is the maximum palette size (default 256). + MaxColors int +} + +// DefaultOptions returns sensible defaults. +func DefaultOptions() *Options { + return &Options{ + Delay: 10, + MaxColors: 256, + } +} + +// Save is a convenience wrapper that writes frames to a GIF file. +func Save(path string, frames []image.Image, opts *Options) error { + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("create gif file: %w", err) + } + defer f.Close() + return Encode(f, frames, opts) +} + +// Encode writes an animated GIF to w. All frames are normalized to a unified +// canvas (max width/height across frames), the palette starts with a +// transparent color, and DisposalBackground prevents inter-frame ghosting. +func Encode(w io.Writer, frames []image.Image, opts *Options) error { + if len(frames) == 0 { + return fmt.Errorf("no frames to encode") + } + if opts == nil { + opts = DefaultOptions() + } + delay := opts.Delay + if delay <= 0 { + delay = 10 + } + maxColors := opts.MaxColors + if maxColors <= 0 || maxColors > 256 { + maxColors = 256 + } + + // Unified canvas + maxW, maxH := 0, 0 + for _, f := range frames { + b := f.Bounds() + if b.Dx() > maxW { + maxW = b.Dx() + } + if b.Dy() > maxH { + maxH = b.Dy() + } + } + + // Palette with transparent at index 0 + pal := color.Palette{color.RGBA{0, 0, 0, 0}} + seen := make(map[color.RGBA]bool) + for _, fr := range frames { + b := fr.Bounds() + for y := b.Min.Y; y < b.Max.Y; y += 3 { + for x := b.Min.X; x < b.Max.X; x += 3 { + r, g, bl, a := fr.At(x, y).RGBA() + c := color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(bl >> 8), uint8(a >> 8)} + if !seen[c] && len(pal) < maxColors-1 { + seen[c] = true + pal = append(pal, c) + } + } + } + } + + anim := &gif.GIF{ + Config: image.Config{Width: maxW, Height: maxH}, + } + + for _, frame := range frames { + pl := image.NewPaletted(image.Rect(0, 0, maxW, maxH), pal) + // Manually map pixels: transparent → index 0, colored → nearest palette + b := frame.Bounds() + for y := 0; y < maxH; y++ { + for x := 0; x < maxW; x++ { + sx := x + b.Min.X + sy := y + b.Min.Y + if sx < b.Max.X && sy < b.Max.Y { + r, g, bl, a := frame.At(sx, sy).RGBA() + if a > 0 { + c := color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(bl >> 8), uint8(a >> 8)} + pl.Set(x, y, c) + } + // else: stays at index 0 (transparent) + } + } + } + + anim.Image = append(anim.Image, pl) + anim.Delay = append(anim.Delay, delay) + anim.Disposal = append(anim.Disposal, gif.DisposalBackground) + } + + anim.LoopCount = 0 + anim.BackgroundIndex = 0 + return gif.EncodeAll(w, anim) +} diff --git a/backend/pkg/splitsprite/splitsprite.go b/backend/pkg/splitsprite/splitsprite.go index 78be163..3166b34 100755 --- a/backend/pkg/splitsprite/splitsprite.go +++ b/backend/pkg/splitsprite/splitsprite.go @@ -58,8 +58,9 @@ func DefaultOptions() *Options { WhiteThreshold: 40, GapThreshold: 0.03, MinGapWidth: 2, - MinFillRatio: 0.3, + MinFillRatio: 0.14, Trim: true, + CenterAlign: true, } } @@ -70,8 +71,9 @@ func DefaultGreenOptions() *Options { GreenTolerance: 0.2, GapThreshold: 0.03, MinGapWidth: 2, - MinFillRatio: 0.3, + MinFillRatio: 0.14, Trim: true, + CenterAlign: true, } } @@ -133,14 +135,15 @@ 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. +// alignCenter aligns all frames to a uniform canvas with a fixed reference point. +// Uses bottom-center alignment so characters share a common ground plane across frames, +// preventing drift/jitter in animation playback. func alignCenter(frames []image.Image) []image.Image { type contentBox struct { minX, minY, maxX, maxY int } boxes := make([]contentBox, len(frames)) - maxW, maxH := 0, 0 + maxCW, maxCH := 0, 0 for i, f := range frames { b := f.Bounds() @@ -172,29 +175,32 @@ func alignCenter(frames []image.Image) []image.Image { } 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 + cw := boxes[i].maxX - boxes[i].minX + 1 + ch := boxes[i].maxY - boxes[i].minY + 1 + if cw > maxCW { + maxCW = cw } - if h > maxH { - maxH = h + if ch > maxCH { + maxCH = ch } } - // Pad by 10% to avoid edge cropping - maxW = maxW * 11 / 10 - maxH = maxH * 11 / 10 + // Uniform canvas with 10% padding + canvasW := maxCW * 11 / 10 + canvasH := maxCH * 11 / 10 + // Fixed X center reference: anchor all frames to the same horizontal center + fixedCenterX := canvasW / 2 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 + // All frames share the same center-X and bottom-Y anchor + ox := fixedCenterX - cw/2 // consistent horizontal center + oy := canvasH - ch // bottom-align: feet planted at same Y - canvas := image.NewRGBA(image.Rect(0, 0, maxW, maxH)) + canvas := image.NewRGBA(image.Rect(0, 0, canvasW, canvasH)) draw.Draw(canvas, image.Rect(ox, oy, ox+cw, oy+ch), f, @@ -229,7 +235,7 @@ type tile struct { x, y, w, h int } -// removeWhiteBg removes pixels close to pure white (R,G,B all above threshold). +// removeWhiteBg removes pixels close to pure white (R,G,B all within threshold of 255). func removeWhiteBg(rgba *image.RGBA, threshold uint8) *image.RGBA { if threshold == 0 { threshold = 40 @@ -245,11 +251,14 @@ func removeWhiteBg(rgba *image.RGBA, threshold uint8) *image.RGBA { 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) + // Distance from pure white + dist := max(int(255-r8), max(int(255-g8), int(255-b8))) + if dist < int(threshold)/2 { + // Very close to white — fully transparent + dst.SetRGBA(x, y, color.RGBA{R: r8, G: g8, B: b8, A: 0}) + } else if dist < int(threshold) { + // Semi-white — fade alpha + alpha := float64(dist-int(threshold)/2) / float64(int(threshold)/2) dst.SetRGBA(x, y, color.RGBA{R: r8, G: g8, B: b8, A: uint8(alpha * 255)}) } } @@ -378,6 +387,90 @@ func tileFillRatio(rgba *image.RGBA, x0, y0, w, h int) float64 { } func findCuts(ratios []float64, threshold float64, minGap int) []int { + n := len(ratios) + if n == 0 { + return nil + } + + // Smooth the ratio curve with a moving average (kernel size = minGap) + smoothed := make([]float64, n) + kernel := max(minGap, 3) + for i := 0; i < n; i++ { + sum := 0.0 + count := 0 + for j := max(0, i-kernel/2); j < min(n, i+kernel/2+1); j++ { + sum += ratios[j] + count++ + } + if count > 0 { + smoothed[i] = sum / float64(count) + } + } + + // Compute mean to use as reference + mean := 0.0 + for _, r := range smoothed { + mean += r + } + mean /= float64(n) + + // Find peaks: contiguous regions where smoothed ratio > mean*1.2 + type segment struct{ start, end int } + var peaks []segment + i := 0 + for i < n { + if smoothed[i] > mean*1.2 { + start := i + for i < n && smoothed[i] > mean*0.8 { + i++ + } + peaks = append(peaks, segment{start, i}) + } else { + i++ + } + } + + if len(peaks) < 2 { + // Fallback: use threshold-based gap detection + return findCutsByGap(ratios, threshold, minGap) + } + + // Find valleys between adjacent peaks (minimum smoothed ratio between them) + cuts := []int{0} + for p := 0; p < len(peaks)-1; p++ { + valleyStart := peaks[p].end + valleyEnd := peaks[p+1].start + if valleyStart >= valleyEnd { + // Peaks adjacent — cut at midpoint + cuts = append(cuts, (peaks[p].end+peaks[p+1].start)/2) + continue + } + // Find minimum in the valley region + minIdx := valleyStart + minVal := smoothed[valleyStart] + for j := valleyStart + 1; j < valleyEnd; j++ { + if smoothed[j] < minVal { + minVal = smoothed[j] + minIdx = j + } + } + cuts = append(cuts, minIdx) + } + cuts = append(cuts, n) + sort.Ints(cuts) + + // Deduplicate + dedup := cuts[:1] + for j := 1; j < len(cuts); j++ { + if cuts[j] != dedup[len(dedup)-1] { + dedup = append(dedup, cuts[j]) + } + } + return dedup +} + +// findCutsByGap is the original threshold-based fallback. +func findCutsByGap(ratios []float64, threshold float64, minGap int) []int { n := len(ratios) isGap := make([]bool, n) for i, r := range ratios { diff --git a/backend/tools/test_prompt_to_gif.go b/backend/tools/test_prompt_to_gif.go index 66decfa..3b9d3b4 100644 --- a/backend/tools/test_prompt_to_gif.go +++ b/backend/tools/test_prompt_to_gif.go @@ -7,14 +7,12 @@ import ( "context" "fmt" "image" - "image/color" - "image/draw" - "image/gif" "image/png" "os" "gen2d/internal/config" "gen2d/internal/service" + "gen2d/pkg/gifmaker" "gen2d/pkg/splitsprite" ) @@ -28,10 +26,10 @@ func main() { // 1. PromptAgent 优化提示词 fmt.Println("=== Step 1: Optimize prompt via PromptAgent ===") agentIn := service.PromptAgentInput{ - Tags: []string{"像素", "战士", "持剑", "精灵表"}, + Tags: []string{"像素", "横版动作", "大剑战士", "精灵表", "4x4网格"}, AssetType: "sprite", - Prompt: "生成一个像素风持剑战士的4方向行走精灵表", - UserNote: "需要4方向(上下左右),每方向4帧行走动画", + Prompt: "生成一个2D横版动作游戏角色的连续攻击连招精灵表,侧视角,4行4列网格排列", + UserNote: "角色为持大剑的战士,连招包含4段攻击:横斩→上挑→跳劈→终结重击。每段攻击4帧关键帧,共16帧,按4行×4列网格排列。帧间留8-16px纯白间隙(#FFFFFF)。要求动作流畅有力量感,大剑挥舞轨迹清晰", } out, err := service.RunPromptAgent(ctx, agentIn) if err != nil { @@ -42,7 +40,7 @@ func main() { // 2. 调用文生图 API fmt.Println("=== Step 2: Generate sprite sheet via image API ===") params := service.AssetParams{ - Resolution: 1024, + Resolution: 1536, Format: "spritesheet", } images, err := service.GenerateImages(ctx, out.Prompt, params) @@ -63,17 +61,13 @@ func main() { } fmt.Printf("Saved sprite sheet → %s (%d bytes)\n", sheetPath, len(images[0].Data)) - // 3. splitsprite 拆分精灵表 - fmt.Println("\n=== Step 3: Split sprite sheet ===") + // 3. splitsprite: 洗白底 → 波谷投影拆分 → 有效像素≥40% → 裁切像素边界 → 统一最大分辨率居中 + fmt.Println("\n=== Step 3: Wash white bg → valley projection split → trim → center align ===") sheetImg, err := decodePNG(images[0].Data) if err != nil { fatalf("decode sheet: %v", err) } - opts := splitsprite.DefaultOptions() - opts.GridRows = 4 - opts.GridCols = 4 - opts.GridPadding = 2 - opts.CenterAlign = true + opts := splitsprite.DefaultOptions() // WhiteBg=true, MinFillRatio=0.4, CenterAlign=true frames, err := splitsprite.Process(sheetImg, opts) if err != nil { fatalf("split failed: %v", err) @@ -92,7 +86,7 @@ func main() { // 4. 生成 GIF 预览 fmt.Println("\n=== Step 4: Generate GIF preview ===") gifPath := "test_output/preview.gif" - if err := genGIF(gifPath, frames, 12); err != nil { + if err := gifmaker.Save(gifPath, frames, nil); err != nil { fatalf("generate GIF: %v", err) } fmt.Printf("GIF preview → %s (%d frames)\n", gifPath, len(frames)) @@ -104,63 +98,6 @@ func main() { fmt.Println(" test_output/preview.gif — animated GIF preview") } -func genGIF(path string, frames []image.Image, delay int) error { - f, err := os.Create(path) - if err != nil { - return err - } - defer f.Close() - - pal := buildPalette(frames) - anim := &gif.GIF{} - for _, frame := range frames { - b := frame.Bounds() - paletted := image.NewPaletted(b, pal) - draw.Draw(paletted, b, frame, b.Min, draw.Src) - anim.Image = append(anim.Image, paletted) - anim.Delay = append(anim.Delay, delay) - } - anim.LoopCount = 0 // loop forever - return gif.EncodeAll(f, anim) -} - -func buildPalette(frames []image.Image) color.Palette { - hist := make(map[color.RGBA]int) - sampleStep := max(1, len(frames)/8) - for i := 0; i < len(frames); i += sampleStep { - b := frames[i].Bounds() - step := max(1, (b.Dx()*b.Dy())/4096) - n := 0 - for y := b.Min.Y; y < b.Max.Y; y++ { - for x := b.Min.X; x < b.Max.X; x++ { - if n%step != 0 { - n++ - continue - } - n++ - r, g, bl, a := frames[i].At(x, y).RGBA() - if a > 0 { - c := color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(bl >> 8), A: uint8(a >> 8)} - hist[c]++ - } - } - } - } - pal := make(color.Palette, 0, 256) - for c := range hist { - pal = append(pal, c) - if len(pal) >= 240 { - break - } - } - pal = append(pal, - color.RGBA{0, 0, 0, 0}, - color.RGBA{0, 0, 0, 255}, - color.RGBA{255, 255, 255, 255}, - ) - return pal -} - func decodePNG(data []byte) (image.Image, error) { img, _, err := image.Decode(bytes.NewReader(data)) if err != nil {