Files
moteva/server/internal/application/layer_separation.go
T

1232 lines
40 KiB
Go
Raw Normal View History

package application
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"io"
"math"
"net/http"
"sort"
"strconv"
"strings"
"time"
"img_infinite_canvas/internal/domain/design"
psd "github.com/oov/psd"
"github.com/zeromicro/go-zero/core/logx"
)
const (
layerSeparationForegroundLabel = "fg_image"
layerSeparationBackgroundLabel = "bg_image"
layerSeparationTextLabel = "text_render_data"
)
type layerSeparationResult struct {
CanvasWidth int64
CanvasHeight int64
BackgroundID string
BackgroundURL string
Foregrounds []layerSeparationForeground
TextExtraction design.TextExtraction
Artifacts []design.GeneratorTaskArtifact
}
type layerSeparationForeground struct {
ID string
URL string
BBox []int64
Width int64
Height int64
Mask *image.Alpha
Rect image.Rectangle
}
type layerSeparationForegroundCandidate struct {
BBox []int64
Width int64
Height int64
Mask *image.Alpha
Rect image.Rectangle
Image image.Image
}
type layerSeparationDocumentLayer struct {
Name string
Rect image.Rectangle
Image image.Image
Opacity uint8
}
type layerSeparationDocument struct {
Bounds image.Rectangle
Layers []layerSeparationDocumentLayer
}
func (s *DesignService) completeLayerSeparationGeneration(ctx context.Context, projectID string, threadID string, target design.Node, req design.NodeActionRequest) error {
resultNodeID := nodeActionResultNodeID(target, req)
if err := s.addGenerationStep(ctx, projectID, threadID, "tool", "图层分离", "正在拆分底图、前景元素和可编辑文字层。"); err != nil {
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, resultNodeID, err)
}
resultTarget := target
resultTarget.ID = resultNodeID
result, err := s.createLayerSeparationResult(ctx, projectID, resultTarget, req)
if err != nil {
return s.markNodeActionFailed(detachedUserContext(ctx), projectID, threadID, resultNodeID, err)
}
project, err := s.repo.Get(ctx, projectID)
if err != nil {
return err
}
nodeIndex := findNodeIndex(project.Nodes, resultNodeID)
if nodeIndex < 0 {
project.Nodes = append(project.Nodes, newLayerSeparationPlaceholder(target))
nodeIndex = len(project.Nodes) - 1
project.Nodes[nodeIndex].ID = resultNodeID
}
frameNode := project.Nodes[nodeIndex]
if frameNode.Width <= 0 {
frameNode.Width = float64(result.CanvasWidth)
}
if frameNode.Height <= 0 {
frameNode.Height = float64(result.CanvasHeight)
}
frameNode.Type = design.NodeTypeFrame
frameNode.Title = "Frame"
frameNode.Content = ""
frameNode.Tone = "frame"
frameNode.Status = "success"
frameNode.ParentID = ""
frameNode.LayerRole = "manual-frame"
frameNode.FillColor = "transparent"
frameNode.StrokeColor = "#C7C7C7"
frameNode.StrokeWidth = 1
frameNode.StrokeStyle = "solid"
frameNode.MockupSurface = design.MockupSurface{}
frameNode.MockupModel = design.MockupModel{}
frameNode.MockupSourceContent = ""
now := s.now()
backgroundNode := newLayerSeparationBackgroundNode(frameNode, result)
foregroundNodes := newForegroundImageLayers(frameNode, result.Foregrounds, result.CanvasWidth, result.CanvasHeight)
editableTextNodes := newEditableTextLayers(frameNode, result.TextExtraction)
replacementNodes := make([]design.Node, 0, len(foregroundNodes)+len(editableTextNodes)+2)
replacementNodes = append(replacementNodes, backgroundNode, frameNode)
replacementNodes = append(replacementNodes, foregroundNodes...)
replacementNodes = append(replacementNodes, editableTextNodes...)
project.Nodes = append(project.Nodes[:nodeIndex], append(replacementNodes, project.Nodes[nodeIndex+1:]...)...)
project.Messages = append(project.Messages, layerSeparationArtifactsMessage(threadID, result.Artifacts, now.Add(-time.Millisecond)))
project.Messages = append(project.Messages, design.Message{
ID: newID(),
Role: "assistant",
Type: "assistant",
Title: nodeActionDoneTitle(req.Action),
Content: nodeActionDoneContent(req.Action, req.Prompt),
ThreadID: threadID,
ActionID: threadID,
StepID: newID(),
Name: "canvas_action",
ToolHint: "canvas_action",
Status: "success",
CreatedAt: now,
})
project.Connections = nil
project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusReady)
project.UpdatedAt = now
project = s.refreshCanvasSnapshot(project, now)
return s.repo.Save(ctx, project)
}
func (s *DesignService) createLayerSeparationResult(ctx context.Context, projectID string, target design.Node, req design.NodeActionRequest) (layerSeparationResult, error) {
sourceData, sourceContentType, err := loadImageContent(ctx, target.Content)
if err != nil {
return layerSeparationResult{}, err
}
source, _, err := image.Decode(bytes.NewReader(sourceData))
if err != nil {
return layerSeparationResult{}, err
}
bounds := source.Bounds()
canvasWidth := int64(bounds.Dx())
canvasHeight := int64(bounds.Dy())
canvas := image.NewRGBA(bounds)
draw.Draw(canvas, bounds, source, bounds.Min, draw.Src)
extraction, err := s.extractLayerSeparationText(ctx, target, req)
if err != nil {
return layerSeparationResult{}, err
}
if result, ok := s.createLayerSeparationResultFromLayeredDocument(ctx, projectID, target, req, source, bounds, extraction); ok {
return result, nil
}
for _, layer := range extraction.Layers {
removeTextLayerFromImage(canvas, layer)
}
foregrounds := make([]layerSeparationForeground, 0, 1)
if fullForeground, elementForegrounds, ok := s.createLayerSeparationForegroundCandidates(ctx, target, sourceData, sourceContentType, bounds, extraction); ok {
if len(extraction.Layers) == 0 {
if plainBackground, ok := plainLayerSeparationBackgroundImage(canvas, fullForeground.Mask, fullForeground.Rect); ok {
if foreground, ok := s.persistLayerSeparationForeground(ctx, projectID, fullForeground); ok {
foregrounds = append(foregrounds, foreground)
}
canvas = plainBackground
}
}
if len(foregrounds) == 0 {
for _, candidate := range elementForegrounds {
foreground, ok := s.persistLayerSeparationForeground(ctx, projectID, candidate)
if !ok {
continue
}
removeForegroundMaskFromImage(canvas, foreground.Mask, foreground.Rect)
foregrounds = append(foregrounds, foreground)
}
}
if len(foregrounds) == 0 && len(extraction.Layers) == 0 {
if foreground, ok := s.persistLayerSeparationForeground(ctx, projectID, fullForeground); ok {
removeForegroundMaskFromImage(canvas, foreground.Mask, foreground.Rect)
foregrounds = append(foregrounds, foreground)
}
}
if len(extraction.Layers) == 0 && len(foregrounds) == 1 {
if plainBackground, ok := plainLayerSeparationBackgroundImage(canvas, foregrounds[0].Mask, foregrounds[0].Rect); ok {
canvas = plainBackground
}
}
}
return s.finishLayerSeparationResult(ctx, projectID, canvasWidth, canvasHeight, canvas, foregrounds, extraction)
}
func (s *DesignService) finishLayerSeparationResult(ctx context.Context, projectID string, canvasWidth int64, canvasHeight int64, canvas image.Image, foregrounds []layerSeparationForeground, extraction design.TextExtraction) (layerSeparationResult, error) {
backgroundID := newID()
backgroundURL, backgroundSize, err := s.persistLayerSeparationImage(ctx, projectID, backgroundID, "background", canvas)
if err != nil {
return layerSeparationResult{}, err
}
textRenderData, err := layerSeparationTextRenderData(extraction, canvasWidth, canvasHeight)
if err != nil {
return layerSeparationResult{}, err
}
artifacts := make([]design.GeneratorTaskArtifact, 0, len(foregrounds)+2)
for _, foreground := range foregrounds {
artifacts = append(artifacts, design.GeneratorTaskArtifact{
Type: "image",
Content: foreground.URL,
Metadata: design.GeneratorTaskArtifactMetadata{
ArtifactID: foreground.ID,
BBox: append([]int64(nil), foreground.BBox...),
Format: generatorArtifactFormat("", foreground.URL),
GeneratorName: layerSeparationGeneratorName,
Height: foreground.Height,
Label: layerSeparationForegroundLabel,
NodeID: foreground.ID,
NodeName: "Foreground Layer",
SizeBytes: 0,
Width: foreground.Width,
},
})
}
artifacts = append(artifacts, design.GeneratorTaskArtifact{
Type: "image",
Content: backgroundURL,
Metadata: design.GeneratorTaskArtifactMetadata{
ArtifactID: backgroundID,
Format: generatorArtifactFormat("", backgroundURL),
GeneratorName: layerSeparationGeneratorName,
Height: canvasHeight,
Label: layerSeparationBackgroundLabel,
NodeID: backgroundID,
NodeName: "Image",
SizeBytes: backgroundSize,
Width: canvasWidth,
},
}, design.GeneratorTaskArtifact{
Type: "text",
Content: textRenderData,
Metadata: design.GeneratorTaskArtifactMetadata{
ArtifactID: newID(),
GeneratorName: layerSeparationGeneratorName,
Height: canvasHeight,
Label: layerSeparationTextLabel,
Width: canvasWidth,
},
})
return layerSeparationResult{
CanvasWidth: canvasWidth,
CanvasHeight: canvasHeight,
BackgroundID: backgroundID,
BackgroundURL: backgroundURL,
Foregrounds: foregrounds,
TextExtraction: extraction,
Artifacts: artifacts,
}, nil
}
func (s *DesignService) extractLayerSeparationText(ctx context.Context, target design.Node, req design.NodeActionRequest) (design.TextExtraction, error) {
if extraction, ok, err := textExtractionFromOptions(req.Options); ok || err != nil {
return extraction, err
}
if s.textExtractor == nil {
return design.TextExtraction{}, nil
}
return s.extractTextExtraction(ctx, target, req)
}
func (s *DesignService) createLayerSeparationResultFromLayeredDocument(ctx context.Context, projectID string, target design.Node, req design.NodeActionRequest, source image.Image, bounds image.Rectangle, extraction design.TextExtraction) (layerSeparationResult, bool) {
if s.layeredDocumentGenerator == nil {
return layerSeparationResult{}, false
}
document, err := s.layeredDocumentGenerator.GenerateLayeredDocument(ctx, design.LayeredDocumentRequest{
ImageURL: target.Content,
Prompt: layerSeparationPSDPrompt(req.Prompt),
Model: "gpt-image-2",
Size: "auto",
OutputFormat: "psd",
IdempotencyKey: nodeActionResultNodeID(target, req) + "-psd",
})
if err != nil {
logx.Errorf("layer separation psd generation failed: %v", err)
return layerSeparationResult{}, false
}
data, err := layeredDocumentData(ctx, document)
if err != nil {
logx.Errorf("layer separation psd download failed: %v", err)
return layerSeparationResult{}, false
}
decoded, err := decodeLayerSeparationDocument(data)
if err != nil {
logx.Errorf("layer separation psd decode failed: %v", err)
return layerSeparationResult{}, false
}
if !decoded.Bounds.Eq(bounds) {
logx.Errorf("layer separation psd dimensions mismatch source=%dx%d psd=%dx%d", bounds.Dx(), bounds.Dy(), decoded.Bounds.Dx(), decoded.Bounds.Dy())
return layerSeparationResult{}, false
}
result, err := s.layerSeparationResultFromDocument(ctx, projectID, source, decoded, extraction)
if err != nil {
logx.Errorf("layer separation psd conversion failed: %v", err)
return layerSeparationResult{}, false
}
return result, true
}
func layerSeparationPSDPrompt(userPrompt string) string {
var builder strings.Builder
builder.WriteString("Create a layered PSD from the provided image for element editing. Preserve the exact canvas size and visual alignment. The bottom layer must be one clean background image with all text and small standalone foreground elements removed. Put each small standalone decorative or object element on its own transparent image layer above the background. Keep all text as editable text layers when possible, preserving text, font style, color, alignment, spacing, rotation, and position. Do not rasterize text into the background. Do not split large poster scenery into foreground layers. Return only the PSD file.")
if prompt := strings.TrimSpace(userPrompt); prompt != "" {
builder.WriteString("\nAdditional instruction: ")
builder.WriteString(prompt)
}
return builder.String()
}
func layeredDocumentData(ctx context.Context, document design.LayeredDocument) ([]byte, error) {
if len(document.Data) > 0 {
return document.Data, nil
}
content := strings.TrimSpace(document.Content)
if content == "" {
return nil, fmt.Errorf("layered document returned empty content")
}
if strings.HasPrefix(content, "data:") {
_, payload, ok := strings.Cut(content, ",")
if !ok {
return nil, fmt.Errorf("invalid layered document data url")
}
return base64.StdEncoding.DecodeString(payload)
}
if !strings.HasPrefix(content, "http://") && !strings.HasPrefix(content, "https://") {
return nil, fmt.Errorf("unsupported layered document url")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, content, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "img-infinite-canvas/1.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("download layered document failed: %s", resp.Status)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 96<<20))
if err != nil {
return nil, err
}
if len(data) == 0 {
return nil, fmt.Errorf("download layered document returned empty body")
}
return data, nil
}
func decodeLayerSeparationDocument(data []byte) (layerSeparationDocument, error) {
doc, _, err := psd.Decode(bytes.NewReader(data), &psd.DecodeOptions{SkipMergedImage: true})
if err != nil {
return layerSeparationDocument{}, err
}
result := layerSeparationDocument{
Bounds: doc.Config.Rect,
Layers: make([]layerSeparationDocumentLayer, 0, len(doc.Layer)),
}
var collect func([]psd.Layer)
collect = func(layers []psd.Layer) {
for i := len(layers) - 1; i >= 0; i-- {
layer := layers[i]
if len(layer.Layer) > 0 {
collect(layer.Layer)
}
if !layer.Visible() || !layer.HasImage() || layer.Picker == nil || layer.Rect.Empty() {
continue
}
name := strings.TrimSpace(layer.UnicodeName)
if name == "" {
name = strings.TrimSpace(layer.Name)
}
result.Layers = append(result.Layers, layerSeparationDocumentLayer{
Name: name,
Rect: layer.Rect,
Image: layer.Picker,
Opacity: layer.Opacity,
})
}
}
collect(doc.Layer)
if result.Bounds.Empty() || len(result.Layers) == 0 {
return layerSeparationDocument{}, fmt.Errorf("layered document has no usable layers")
}
return result, nil
}
func (s *DesignService) layerSeparationResultFromDocument(ctx context.Context, projectID string, source image.Image, document layerSeparationDocument, extraction design.TextExtraction) (layerSeparationResult, error) {
backgroundIndex := layerSeparationBackgroundLayerIndex(document)
if backgroundIndex < 0 {
return layerSeparationResult{}, fmt.Errorf("layered document has no background layer")
}
background := image.NewRGBA(document.Bounds)
draw.Draw(background, document.Bounds, source, source.Bounds().Min, draw.Src)
drawLayerSeparationDocumentLayer(background, document.Layers[backgroundIndex])
for _, layer := range extraction.Layers {
removeTextLayerFromImage(background, layer)
}
foregrounds := make([]layerSeparationForeground, 0, len(document.Layers))
allowLargeForeground := len(extraction.Layers) == 0 && layerSeparationImageIsPlain(background, document.Bounds)
for index, layer := range document.Layers {
if index == backgroundIndex || layerSeparationDocumentLayerIsText(layer, extraction, document.Bounds) {
continue
}
candidate, ok := layerSeparationForegroundCandidateFromDocumentLayer(layer, document.Bounds)
if !ok {
continue
}
count := alphaMaskCount(candidate.Mask, candidate.Rect)
if !keepLayerSeparationElement(candidate.Rect, count, document.Bounds) && !(allowLargeForeground && keepLayerSeparationLargeSubject(candidate.Rect, count, document.Bounds)) {
continue
}
foreground, ok := s.persistLayerSeparationForeground(ctx, projectID, candidate)
if !ok {
continue
}
removeForegroundMaskFromImage(background, foreground.Mask, foreground.Rect)
foregrounds = append(foregrounds, foreground)
}
return s.finishLayerSeparationResult(ctx, projectID, int64(document.Bounds.Dx()), int64(document.Bounds.Dy()), background, foregrounds, extraction)
}
func layerSeparationImageIsPlain(img *image.RGBA, bounds image.Rectangle) bool {
if img == nil || bounds.Empty() {
return false
}
mask := image.NewAlpha(bounds)
stats, ok := unmaskedColorStats(img, mask, bounds)
return ok && stats.StdDev <= 24
}
func keepLayerSeparationLargeSubject(rect image.Rectangle, count int, bounds image.Rectangle) bool {
if rect.Empty() || count <= 0 {
return false
}
totalArea := bounds.Dx() * bounds.Dy()
if totalArea <= 0 {
return false
}
bboxArea := rect.Dx() * rect.Dy()
if float64(bboxArea)/float64(totalArea) > 0.78 {
return false
}
if float64(rect.Dx())/float64(bounds.Dx()) > 0.9 || float64(rect.Dy())/float64(bounds.Dy()) > 0.96 {
return false
}
return count >= totalArea/80
}
func layerSeparationBackgroundLayerIndex(document layerSeparationDocument) int {
bestIndex := -1
bestScore := -1
totalArea := document.Bounds.Dx() * document.Bounds.Dy()
for index, layer := range document.Layers {
if layer.Rect.Empty() || layer.Image == nil {
continue
}
name := strings.ToLower(layer.Name)
area := layer.Rect.Intersect(document.Bounds).Dx() * layer.Rect.Intersect(document.Bounds).Dy()
if strings.Contains(name, "bg") || strings.Contains(name, "background") || strings.Contains(name, "clean") || strings.Contains(name, "base") {
return index
}
if area > bestScore {
bestScore = area
bestIndex = index
}
}
if totalArea <= 0 || bestScore < int(float64(totalArea)*0.45) {
return -1
}
return bestIndex
}
func drawLayerSeparationDocumentLayer(dst *image.RGBA, layer layerSeparationDocumentLayer) {
if dst == nil || layer.Image == nil || layer.Rect.Empty() {
return
}
rect := layer.Rect.Intersect(dst.Bounds())
if rect.Empty() {
return
}
if layer.Opacity == 0 {
return
}
if layer.Opacity == 255 {
draw.Draw(dst, rect, layer.Image, rect.Min, draw.Src)
return
}
tmp := image.NewNRGBA(rect)
draw.Draw(tmp, rect, layer.Image, rect.Min, draw.Src)
for y := rect.Min.Y; y < rect.Max.Y; y++ {
for x := rect.Min.X; x < rect.Max.X; x++ {
offset := tmp.PixOffset(x, y)
tmp.Pix[offset+3] = uint8(int(tmp.Pix[offset+3]) * int(layer.Opacity) / 255)
}
}
draw.Draw(dst, rect, tmp, rect.Min, draw.Over)
}
func layerSeparationDocumentLayerIsText(layer layerSeparationDocumentLayer, extraction design.TextExtraction, bounds image.Rectangle) bool {
name := strings.ToLower(strings.TrimSpace(layer.Name))
if strings.Contains(name, "text") || strings.Contains(name, "type") || strings.Contains(name, "font") || strings.Contains(name, "文字") {
return true
}
if len(extraction.Layers) == 0 || layer.Rect.Empty() {
return false
}
layerRect := layer.Rect.Intersect(bounds)
if layerRect.Empty() {
return false
}
layerArea := float64(layerRect.Dx() * layerRect.Dy())
for _, textLayer := range extraction.Layers {
textRect := normalizedTextMaskRect(bounds, textLayer)
intersection := layerRect.Intersect(textRect)
if intersection.Empty() {
continue
}
intersectionArea := float64(intersection.Dx() * intersection.Dy())
if intersectionArea/layerArea > 0.28 {
return true
}
}
return false
}
func layerSeparationForegroundCandidateFromDocumentLayer(layer layerSeparationDocumentLayer, bounds image.Rectangle) (layerSeparationForegroundCandidate, bool) {
if layer.Image == nil || layer.Rect.Empty() {
return layerSeparationForegroundCandidate{}, false
}
if layer.Opacity == 0 {
return layerSeparationForegroundCandidate{}, false
}
foreground := image.NewNRGBA(bounds)
rect := layer.Rect.Intersect(bounds)
if rect.Empty() {
return layerSeparationForegroundCandidate{}, false
}
draw.Draw(foreground, rect, layer.Image, rect.Min, draw.Src)
if layer.Opacity > 0 && layer.Opacity < 255 {
for y := rect.Min.Y; y < rect.Max.Y; y++ {
for x := rect.Min.X; x < rect.Max.X; x++ {
offset := foreground.PixOffset(x, y)
foreground.Pix[offset+3] = uint8(int(foreground.Pix[offset+3]) * int(layer.Opacity) / 255)
}
}
}
contentRect, ok := alphaContentBounds(foreground, bounds, 12)
if !ok {
return layerSeparationForegroundCandidate{}, false
}
mask, count := alphaMask(foreground, bounds, 12)
if count == 0 {
return layerSeparationForegroundCandidate{}, false
}
return newLayerSeparationForegroundCandidate(foreground, mask, contentRect, bounds), true
}
func alphaMaskCount(mask *image.Alpha, rect image.Rectangle) int {
if mask == nil || rect.Empty() {
return 0
}
count := 0
rect = rect.Intersect(mask.Bounds())
for y := rect.Min.Y; y < rect.Max.Y; y++ {
for x := rect.Min.X; x < rect.Max.X; x++ {
if mask.AlphaAt(x, y).A > 0 {
count++
}
}
}
return count
}
func (s *DesignService) createLayerSeparationForegroundCandidates(ctx context.Context, target design.Node, sourceData []byte, sourceContentType string, bounds image.Rectangle, extraction design.TextExtraction) (layerSeparationForegroundCandidate, []layerSeparationForegroundCandidate, bool) {
if s.backgroundRemover == nil {
return layerSeparationForegroundCandidate{}, nil, false
}
removed, err := s.backgroundRemover.RemoveBackground(ctx, design.BackgroundRemovalRequest{
Image: sourceData,
ContentType: sourceContentType,
Width: bounds.Dx(),
Height: bounds.Dy(),
})
if err != nil || len(removed.Image) == 0 {
return layerSeparationForegroundCandidate{}, nil, false
}
foregroundImage, _, err := image.Decode(bytes.NewReader(removed.Image))
if err != nil {
return layerSeparationForegroundCandidate{}, nil, false
}
foreground := image.NewNRGBA(foregroundImage.Bounds())
draw.Draw(foreground, foreground.Bounds(), foregroundImage, foregroundImage.Bounds().Min, draw.Src)
eraseTextLayersFromForeground(foreground, extraction)
foregroundBounds := foreground.Bounds()
rect, ok := alphaContentBounds(foreground, foregroundBounds, 12)
if !ok {
return layerSeparationForegroundCandidate{}, nil, false
}
mask, count := alphaMask(foreground, foregroundBounds, 12)
if count == 0 {
return layerSeparationForegroundCandidate{}, nil, false
}
full := newLayerSeparationForegroundCandidate(foreground, mask, rect, foregroundBounds)
return full, layerSeparationElementCandidates(foreground, foregroundBounds), true
}
func (s *DesignService) persistLayerSeparationForeground(ctx context.Context, projectID string, candidate layerSeparationForegroundCandidate) (layerSeparationForeground, bool) {
if candidate.Image == nil || candidate.Mask == nil || candidate.Rect.Empty() || len(candidate.BBox) < 4 {
return layerSeparationForeground{}, false
}
foregroundID := newID()
foregroundURL, _, err := s.persistLayerSeparationImage(ctx, projectID, foregroundID, "foreground", candidate.Image)
if err != nil {
return layerSeparationForeground{}, false
}
return layerSeparationForeground{
ID: foregroundID,
URL: foregroundURL,
BBox: append([]int64(nil), candidate.BBox...),
Width: candidate.Width,
Height: candidate.Height,
Mask: candidate.Mask,
Rect: candidate.Rect,
}, true
}
func newLayerSeparationForegroundCandidate(foreground *image.NRGBA, mask *image.Alpha, rect image.Rectangle, bounds image.Rectangle) layerSeparationForegroundCandidate {
return layerSeparationForegroundCandidate{
BBox: []int64{
int64(rect.Min.X - bounds.Min.X),
int64(rect.Min.Y - bounds.Min.Y),
int64(rect.Max.X - bounds.Min.X),
int64(rect.Max.Y - bounds.Min.Y),
},
Width: int64(rect.Dx()),
Height: int64(rect.Dy()),
Mask: mask,
Rect: rect,
Image: foregroundImageFromMask(foreground, mask, rect),
}
}
func eraseTextLayersFromForeground(foreground *image.NRGBA, extraction design.TextExtraction) {
if foreground == nil || len(extraction.Layers) == 0 {
return
}
bounds := foreground.Bounds()
if bounds.Dx()*bounds.Dy() < 4096 {
return
}
for _, layer := range extraction.Layers {
rect := normalizedTextMaskRect(bounds, layer)
if rect.Empty() {
continue
}
padding := clampInt(int(math.Round(math.Min(float64(rect.Dx()), float64(rect.Dy()))*0.14)), 2, 18)
rect = image.Rect(rect.Min.X-padding, rect.Min.Y-padding, rect.Max.X+padding, rect.Max.Y+padding).Intersect(bounds)
for y := rect.Min.Y; y < rect.Max.Y; y++ {
for x := rect.Min.X; x < rect.Max.X; x++ {
offset := foreground.PixOffset(x, y)
foreground.Pix[offset+3] = 0
}
}
}
}
func layerSeparationElementCandidates(foreground *image.NRGBA, bounds image.Rectangle) []layerSeparationForegroundCandidate {
if foreground == nil || bounds.Empty() {
return nil
}
width := bounds.Dx()
height := bounds.Dy()
visited := make([]bool, width*height)
components := make([]layerSeparationForegroundCandidate, 0, 4)
indexOf := func(x int, y int) int {
return (y-bounds.Min.Y)*width + (x - bounds.Min.X)
}
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
index := indexOf(x, y)
if visited[index] || foreground.NRGBAAt(x, y).A <= 12 {
visited[index] = true
continue
}
mask, rect, count := layerSeparationComponentMask(foreground, bounds, visited, x, y, indexOf)
if !keepLayerSeparationElement(rect, count, bounds) {
continue
}
components = append(components, newLayerSeparationForegroundCandidate(foreground, mask, rect, bounds))
}
}
sort.Slice(components, func(i int, j int) bool {
leftArea := components[i].Rect.Dx() * components[i].Rect.Dy()
rightArea := components[j].Rect.Dx() * components[j].Rect.Dy()
return leftArea > rightArea
})
if len(components) > 12 {
components = components[:12]
}
return components
}
func layerSeparationComponentMask(foreground *image.NRGBA, bounds image.Rectangle, visited []bool, startX int, startY int, indexOf func(int, int) int) (*image.Alpha, image.Rectangle, int) {
mask := image.NewAlpha(bounds)
stack := []image.Point{{X: startX, Y: startY}}
minX, minY := startX, startY
maxX, maxY := startX+1, startY+1
count := 0
for len(stack) > 0 {
point := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if point.X < bounds.Min.X || point.X >= bounds.Max.X || point.Y < bounds.Min.Y || point.Y >= bounds.Max.Y {
continue
}
index := indexOf(point.X, point.Y)
if visited[index] {
continue
}
visited[index] = true
if foreground.NRGBAAt(point.X, point.Y).A <= 12 {
continue
}
mask.SetAlpha(point.X, point.Y, color.Alpha{A: 255})
count++
if point.X < minX {
minX = point.X
}
if point.Y < minY {
minY = point.Y
}
if point.X+1 > maxX {
maxX = point.X + 1
}
if point.Y+1 > maxY {
maxY = point.Y + 1
}
stack = append(stack,
image.Point{X: point.X + 1, Y: point.Y},
image.Point{X: point.X - 1, Y: point.Y},
image.Point{X: point.X, Y: point.Y + 1},
image.Point{X: point.X, Y: point.Y - 1},
)
}
return mask, image.Rect(minX, minY, maxX, maxY), count
}
func keepLayerSeparationElement(rect image.Rectangle, count int, bounds image.Rectangle) bool {
if rect.Empty() || count <= 0 {
return false
}
totalArea := bounds.Dx() * bounds.Dy()
if totalArea <= 0 {
return false
}
if totalArea < 4096 {
return true
}
bboxArea := rect.Dx() * rect.Dy()
minArea := maxInt(96, totalArea/6000)
if count < minArea || bboxArea < minArea {
return false
}
if float64(bboxArea)/float64(totalArea) > 0.18 {
return false
}
if float64(rect.Dx())/float64(bounds.Dx()) > 0.62 || float64(rect.Dy())/float64(bounds.Dy()) > 0.62 {
return false
}
return true
}
func foregroundImageFromMask(foreground *image.NRGBA, mask *image.Alpha, rect image.Rectangle) image.Image {
cropped := image.NewNRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy()))
for y := rect.Min.Y; y < rect.Max.Y; y++ {
for x := rect.Min.X; x < rect.Max.X; x++ {
if mask.AlphaAt(x, y).A == 0 {
continue
}
cropped.SetNRGBA(x-rect.Min.X, y-rect.Min.Y, foreground.NRGBAAt(x, y))
}
}
return cropped
}
func (s *DesignService) persistLayerSeparationImage(ctx context.Context, projectID string, nodeID string, suffix string, img image.Image) (string, int64, error) {
var output bytes.Buffer
if err := png.Encode(&output, img); err != nil {
return "", 0, err
}
if s.assets == nil {
data := output.Bytes()
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data), int64(len(data)), nil
}
data, contentType, err := encodeRasterAssetAsWebP(output.Bytes(), "image/png")
if err != nil {
return "", 0, err
}
object, err := s.assets.PutObject(ctx, design.AssetObjectRequest{
FileName: fmt.Sprintf("%s-%s-%s.png", projectID, nodeID, suffix),
ContentType: contentType,
Data: data,
})
if err != nil {
return "", 0, err
}
if strings.TrimSpace(object.PublicURL) == "" {
return "", 0, fmt.Errorf("layer separation upload returned empty public URL")
}
return object.PublicURL, int64(len(data)), nil
}
func newLayerSeparationBackgroundNode(frame design.Node, result layerSeparationResult) design.Node {
return design.Node{
ID: result.BackgroundID,
Type: design.NodeTypeImage,
Title: "Image",
X: frame.X,
Y: frame.Y,
Width: frame.Width,
Height: frame.Height,
Content: result.BackgroundURL,
Tone: "visual",
Status: "success",
LayerRole: "clean-image",
}
}
func newForegroundImageLayers(target design.Node, foregrounds []layerSeparationForeground, canvasWidth int64, canvasHeight int64) []design.Node {
if len(foregrounds) == 0 || canvasWidth <= 0 || canvasHeight <= 0 {
return nil
}
nodes := make([]design.Node, 0, len(foregrounds))
for index, foreground := range foregrounds {
if len(foreground.BBox) < 4 || strings.TrimSpace(foreground.URL) == "" {
continue
}
x1 := clampFloat(float64(foreground.BBox[0])/float64(canvasWidth), 0, 1)
y1 := clampFloat(float64(foreground.BBox[1])/float64(canvasHeight), 0, 1)
x2 := clampFloat(float64(foreground.BBox[2])/float64(canvasWidth), x1, 1)
y2 := clampFloat(float64(foreground.BBox[3])/float64(canvasHeight), y1, 1)
width := target.Width * (x2 - x1)
height := target.Height * (y2 - y1)
if width <= 1 || height <= 1 {
continue
}
nodes = append(nodes, design.Node{
ID: foreground.ID,
Type: design.NodeTypeImage,
Title: fmt.Sprintf("Foreground Layer %d", index+1),
X: target.X + target.Width*x1,
Y: target.Y + target.Height*y1,
Width: width,
Height: height,
Content: foreground.URL,
Tone: "separated-element",
Status: "success",
ParentID: target.ID,
LayerRole: "foreground-image",
})
}
return nodes
}
func layerSeparationArtifactsMessage(threadID string, artifacts []design.GeneratorTaskArtifact, createdAt time.Time) design.Message {
content, _ := json.Marshal(toLayerSeparationArtifactWire(artifacts))
return design.Message{
ID: newID(),
Role: "tool",
Type: "tool_use",
Title: layerSeparationMessageName,
Content: string(content),
ThreadID: threadID,
ActionID: threadID,
StepID: newID(),
ToolCallID: "call_" + strings.ReplaceAll(newID(), "-", ""),
Name: layerSeparationMessageName,
ToolHint: layerSeparationMessageName,
Status: "success",
CreatedAt: createdAt,
}
}
func toLayerSeparationArtifactWire(artifacts []design.GeneratorTaskArtifact) []map[string]any {
items := make([]map[string]any, 0, len(artifacts))
for _, artifact := range artifacts {
items = append(items, map[string]any{
"type": artifact.Type,
"content": artifact.Content,
"metadata": map[string]any{
"artifact_id": artifact.Metadata.ArtifactID,
"bbox": artifact.Metadata.BBox,
"format": artifact.Metadata.Format,
"generator_name": artifact.Metadata.GeneratorName,
"height": artifact.Metadata.Height,
"label": artifact.Metadata.Label,
"node_id": artifact.Metadata.NodeID,
"node_name": artifact.Metadata.NodeName,
"size_bytes": artifact.Metadata.SizeBytes,
"width": artifact.Metadata.Width,
},
})
}
return items
}
func removeForegroundMaskFromImage(img *image.RGBA, mask *image.Alpha, rect image.Rectangle) {
if mask == nil || rect.Empty() {
return
}
bounds := img.Bounds()
rect = rect.Intersect(bounds)
if rect.Empty() {
return
}
radius := clampInt(int(math.Round(math.Min(float64(rect.Dx()), float64(rect.Dy()))*0.025)), 2, 10)
mask, count := dilateTextMask(mask, rect, radius)
if count == 0 {
return
}
source := image.NewRGBA(bounds)
draw.Draw(source, bounds, img, bounds.Min, draw.Src)
for y := rect.Min.Y; y < rect.Max.Y; y++ {
for x := rect.Min.X; x < rect.Max.X; x++ {
if mask.AlphaAt(x, y).A == 0 {
continue
}
img.SetRGBA(x, y, estimatedRectBackgroundColor(source, rect, x, y))
}
}
softenMaskedPixels(img, mask, rect)
}
func plainLayerSeparationBackgroundImage(img *image.RGBA, mask *image.Alpha, rect image.Rectangle) (*image.RGBA, bool) {
if img == nil || mask == nil || rect.Empty() {
return nil, false
}
bounds := img.Bounds()
if bounds.Empty() {
return nil, false
}
radius := clampInt(int(math.Round(math.Min(float64(rect.Dx()), float64(rect.Dy()))*0.02)), 3, 18)
expandedMask, _ := dilateTextMask(mask, bounds, radius)
stats, ok := unmaskedColorStats(img, expandedMask, bounds)
if !ok || stats.StdDev > 24 {
return nil, false
}
bg := image.NewRGBA(bounds)
fill := colorRGBA(int(math.Round(stats.R)), int(math.Round(stats.G)), int(math.Round(stats.B)), int(math.Round(stats.A)))
draw.Draw(bg, bounds, &image.Uniform{C: fill}, image.Point{}, draw.Src)
return bg, true
}
type layerSeparationColorStats struct {
Count int64
R float64
G float64
B float64
A float64
StdDev float64
}
func unmaskedColorStats(img *image.RGBA, mask *image.Alpha, bounds image.Rectangle) (layerSeparationColorStats, bool) {
stride := clampInt(int(math.Ceil(float64(maxInt(bounds.Dx(), bounds.Dy()))/640)), 1, 12)
var count int64
var r, g, b, a float64
var rr, gg, bb float64
for y := bounds.Min.Y; y < bounds.Max.Y; y += stride {
for x := bounds.Min.X; x < bounds.Max.X; x += stride {
if mask.AlphaAt(x, y).A != 0 {
continue
}
c := img.RGBAAt(x, y)
cfR := float64(c.R)
cfG := float64(c.G)
cfB := float64(c.B)
cfA := float64(c.A)
r += cfR
g += cfG
b += cfB
a += cfA
rr += cfR * cfR
gg += cfG * cfG
bb += cfB * cfB
count++
}
}
if count < 32 {
return layerSeparationColorStats{}, false
}
divisor := float64(count)
meanR := r / divisor
meanG := g / divisor
meanB := b / divisor
varR := math.Max(0, rr/divisor-meanR*meanR)
varG := math.Max(0, gg/divisor-meanG*meanG)
varB := math.Max(0, bb/divisor-meanB*meanB)
return layerSeparationColorStats{
Count: count,
R: meanR,
G: meanG,
B: meanB,
A: a / divisor,
StdDev: math.Sqrt((varR + varG + varB) / 3),
}, true
}
func alphaContentBounds(img image.Image, bounds image.Rectangle, threshold uint8) (image.Rectangle, bool) {
minX, minY := bounds.Max.X, bounds.Max.Y
maxX, maxY := bounds.Min.X, bounds.Min.Y
found := false
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
if alphaAt(img, x, y) <= threshold {
continue
}
if x < minX {
minX = x
}
if y < minY {
minY = y
}
if x+1 > maxX {
maxX = x + 1
}
if y+1 > maxY {
maxY = y + 1
}
found = true
}
}
if !found {
return image.Rectangle{}, false
}
return image.Rect(minX, minY, maxX, maxY), true
}
func alphaMask(img image.Image, bounds image.Rectangle, threshold uint8) (*image.Alpha, int) {
mask := image.NewAlpha(bounds)
count := 0
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
if alphaAt(img, x, y) <= threshold {
continue
}
mask.SetAlpha(x, y, color.Alpha{A: 255})
count++
}
}
return mask, count
}
func alphaAt(img image.Image, x int, y int) uint8 {
_, _, _, a := img.At(x, y).RGBA()
return uint8(a >> 8)
}
func cropImage(img image.Image, rect image.Rectangle) image.Image {
cropped := image.NewNRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy()))
draw.Draw(cropped, cropped.Bounds(), img, rect.Min, draw.Src)
return cropped
}
type layerSeparationTextDocument struct {
Layers []layerSeparationTextLayer `json:"layers"`
CanvasSize layerSeparationCanvasSize `json:"canvas_size"`
}
type layerSeparationCanvasSize struct {
Width int64 `json:"width"`
Height int64 `json:"height"`
}
type layerSeparationTextLayer struct {
TextInfo layerSeparationTextInfo `json:"text_info"`
X float64 `json:"x"`
Y float64 `json:"y"`
W float64 `json:"w"`
H float64 `json:"h"`
}
type layerSeparationTextInfo struct {
Text string `json:"text"`
FontSizePx int64 `json:"font_size_px"`
FontFamily string `json:"font_family"`
FontWeight any `json:"font_weight"`
FontStyle string `json:"font_style"`
ColorCSS string `json:"color_css"`
TextAlign string `json:"text_align"`
Leading float64 `json:"leading"`
Tracking float64 `json:"tracking"`
Rotation float64 `json:"rotation"`
LeadingTrim string `json:"leading_trim"`
}
func layerSeparationTextRenderData(extraction design.TextExtraction, canvasWidth int64, canvasHeight int64) (string, error) {
doc := layerSeparationTextDocument{
Layers: make([]layerSeparationTextLayer, 0, len(extraction.Layers)),
CanvasSize: layerSeparationCanvasSize{
Width: canvasWidth,
Height: canvasHeight,
},
}
for _, layer := range extraction.Layers {
text := strings.TrimSpace(layer.Text)
if text == "" {
continue
}
x := clamp01(layer.X)
y := clamp01(layer.Y)
width := clamp01(layer.Width)
height := clamp01(layer.Height)
if x+width > 1 {
width = 1 - x
}
if y+height > 1 {
height = 1 - y
}
if width <= 0 || height <= 0 {
continue
}
fontSize := layer.FontSize
if fontSize <= 0 {
fontSize = math.Max(10, float64(canvasHeight)*height*0.72)
}
lineHeight := layer.LineHeight
if lineHeight <= 0 {
lineHeight = 1.2
}
fontFamily := strings.TrimSpace(layer.FontFamily)
if fontFamily == "" {
fontFamily = "Inter"
}
doc.Layers = append(doc.Layers, layerSeparationTextLayer{
TextInfo: layerSeparationTextInfo{
Text: text,
FontSizePx: int64(math.Round(fontSize)),
FontFamily: fontFamily,
FontWeight: layerSeparationFontWeight(layer.FontWeight),
FontStyle: layerSeparationFontStyle(layer.FontWeight),
ColorCSS: layerSeparationColorCSS(layer.Color),
TextAlign: layerSeparationTextAlign(layer.TextAlign),
Leading: lineHeight,
Tracking: layer.LetterSpacing,
Rotation: layer.Rotation,
LeadingTrim: "CAP_HEIGHT",
},
X: x * float64(canvasWidth),
Y: y * float64(canvasHeight),
W: width * float64(canvasWidth),
H: height * float64(canvasHeight),
})
}
data, err := json.Marshal(doc)
if err != nil {
return "", err
}
return string(data), nil
}
func layerSeparationFontWeight(value string) any {
value = strings.TrimSpace(value)
if value == "" {
return 400
}
if weight, err := strconv.Atoi(value); err == nil {
return weight
}
return value
}
func layerSeparationFontStyle(value string) string {
if strings.Contains(strings.ToLower(strings.TrimSpace(value)), "italic") {
return "italic"
}
return "normal"
}
func layerSeparationTextAlign(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "left":
return "start"
case "right":
return "end"
case "center":
return "center"
default:
return "start"
}
}
func layerSeparationColorCSS(value string) string {
parsed, ok := parseCanvasColor(value)
if !ok {
return "rgba(25, 25, 25, 1.0)"
}
alpha := float64(parsed.A) / 255
return fmt.Sprintf("rgba(%d, %d, %d, %.3g)", parsed.R, parsed.G, parsed.B, alpha)
}