From 8309d32d4b2dd0906542cb3ef6a5410a47945443 Mon Sep 17 00:00:00 2001 From: Gmaker689 <1711322114@qq.com> Date: Mon, 25 May 2026 17:57:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8A=95=E5=BD=B1=E6=B3=95=E6=B3=A2?= =?UTF-8?q?=E8=B0=B7=E6=A3=80=E6=B5=8B+=E7=99=BD=E5=BA=95=E7=A1=AC?= =?UTF-8?q?=E5=88=87=E6=96=AD+=E5=B8=A7=E5=BA=95=E9=83=A8=E5=AF=B9?= =?UTF-8?q?=E9=BD=90+gifmaker=E9=80=8F=E6=98=8EGIF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prompt_agent: 精灵表/瓦片集间隙从2-4px放宽到8-16px纯白 - splitsprite removeWhiteBg: dist 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 {