Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions examples/client/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,9 @@ func createAnimatedSprite(ctx context.Context, session *mcp.ClientSession, logge

// Step 9: Demonstrate dithering (create a new sprite with gradient)
logger.Information("")
logger.Information("Step 9: Creating sprite with dithered gradient...")
logger.Information("Step 9: Creating sprite for dithering comparison (Bayer vs Floyd-Steinberg)...")
ditherResp, err := callTool(ctx, session, "create_canvas", map[string]any{
"width": 64,
"width": 128,
"height": 64,
"color_mode": "rgb",
})
Expand All @@ -264,9 +264,9 @@ func createAnimatedSprite(ctx context.Context, session *mcp.ClientSession, logge
ditherSprite := ditherResult.FilePath
logger.Information(" Created: {DitherSprite}", ditherSprite)

// Apply dithering with Bayer 4x4 pattern
// Apply Bayer 4x4 dithering to left half
logger.Information("")
logger.Information("Step 10: Applying Bayer 4x4 dithering pattern...")
logger.Information("Step 10: Applying Bayer 4x4 pattern (left half)...")
if _, err := callTool(ctx, session, "draw_with_dither", map[string]any{
"sprite_path": ditherSprite,
"layer_name": "Layer 1",
Expand All @@ -282,14 +282,35 @@ func createAnimatedSprite(ctx context.Context, session *mcp.ClientSession, logge
"pattern": "bayer_4x4",
"density": 0.5,
}); err != nil {
return fmt.Errorf("draw_with_dither failed: %w", err)
return fmt.Errorf("draw_with_dither (bayer) failed: %w", err)
}
logger.Information(" Bayer 4x4 pattern applied (ordered dithering)")

// Apply Floyd-Steinberg dithering to right half
logger.Information(" Applying Floyd-Steinberg pattern (right half)...")
if _, err := callTool(ctx, session, "draw_with_dither", map[string]any{
"sprite_path": ditherSprite,
"layer_name": "Layer 1",
"frame_number": 1,
"region": map[string]any{
"x": 64,
"y": 0,
"width": 64,
"height": 64,
},
"color1": "#001F3F",
"color2": "#7FDBFF",
"pattern": "floyd_steinberg",
"density": 0.5,
}); err != nil {
return fmt.Errorf("draw_with_dither (floyd-steinberg) failed: %w", err)
}
logger.Information(" Dithering applied successfully")
logger.Information(" Floyd-Steinberg pattern applied (error diffusion)")

// Export dithered sprite
logger.Information("")
logger.Information("Step 11: Exporting dithered gradient...")
ditherPngPath := filepath.Join(outputDir, "dithered-gradient.png")
logger.Information("Step 11: Exporting dithering comparison...")
ditherPngPath := filepath.Join(outputDir, "dithering-comparison.png")
if _, err := callTool(ctx, session, "export_sprite", map[string]any{
"sprite_path": ditherSprite,
"output_path": ditherPngPath,
Expand All @@ -299,6 +320,7 @@ func createAnimatedSprite(ctx context.Context, session *mcp.ClientSession, logge
return fmt.Errorf("export_sprite dither failed: %w", err)
}
logger.Information(" Exported: {DitherPng}", ditherPngPath)
logger.Information(" Left: Bayer 4x4 (ordered pattern) | Right: Floyd-Steinberg (error diffusion)")

// Step 12: Analyze palette harmonies
logger.Information("")
Expand Down
238 changes: 238 additions & 0 deletions pkg/aseprite/lua_drawing.go
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,9 @@ local matrixSize = 4`
{1, 0, 1, 0}
}
local matrixSize = 4`
case "floyd_steinberg":
// Floyd-Steinberg uses error diffusion instead of matrix patterns
return generateFloydSteinbergLua(escapedLayerName, frameNumber, x, y, width, height, c1, c2, density)
default:
return fmt.Sprintf(`error("Unknown dithering pattern: %s")`, pattern)
}
Expand Down Expand Up @@ -809,3 +812,238 @@ print("Dithering applied successfully")`,
height, width,
x, y)
}

// generateFloydSteinbergLua generates Lua script for Floyd-Steinberg error diffusion dithering.
//
// Floyd-Steinberg is an error diffusion algorithm that produces high-quality dithering
// by propagating quantization error to neighboring pixels. The error distribution pattern is:
//
// X 7/16
// 3/16 5/16 1/16
//
// where X is the current pixel being processed.
//
// Parameters match DrawWithDither but the density parameter is interpreted as a threshold:
// pixels with luminance < density*255 map to color2, otherwise color1.
Comment thread
willibrandon marked this conversation as resolved.
Outdated
func generateFloydSteinbergLua(layerName string, frameNumber int, x, y, width, height int, c1, c2 Color, density float64) string {
return fmt.Sprintf(`local spr = app.activeSprite
if not spr then
error("No active sprite")
end

-- Find layer
local layer = nil
for _, l in ipairs(spr.layers) do
if l.name == "%s" then
layer = l
break
end
end

if not layer then
error("Layer not found: %s")
end

-- Get frame
local frame = spr.frames[%d]
if not frame then
error("Frame not found: %d")
end

-- Get or create cel
local cel = layer:cel(frame)
if not cel then
cel = spr:newCel(layer, frame)
end

-- Create or get image
local img = cel.image
if not img then
img = Image(spr.width, spr.height, spr.colorMode)
cel.image = img
end

-- Helper: Find nearest palette index for given RGBA color
local function findNearestPaletteIndex(r, g, b, a)
local palette = spr.palettes[1]
if not palette or #palette == 0 then
return 0
end

local minDist = math.huge
local nearestIndex = 0

for i = 0, #palette - 1 do
local palColor = palette:getColor(i)
local dr = r - palColor.red
local dg = g - palColor.green
local db = b - palColor.blue
local da = a - palColor.alpha
local dist = dr*dr + dg*dg + db*db + da*da

if dist < minDist then
minDist = dist
nearestIndex = i
end
end

return nearestIndex
end

-- Define colors based on color mode
local color1, color2
local color1_r, color1_g, color1_b, color1_a = %d, %d, %d, %d
local color2_r, color2_g, color2_b, color2_a = %d, %d, %d, %d

if spr.colorMode == ColorMode.INDEXED then
-- In indexed mode, img:drawPixel expects palette indices
color1 = findNearestPaletteIndex(color1_r, color1_g, color1_b, color1_a)
color2 = findNearestPaletteIndex(color2_r, color2_g, color2_b, color2_a)
else
-- In RGB/Grayscale mode, use pixel color values
color1 = app.pixelColor.rgba(color1_r, color1_g, color1_b, color1_a)
color2 = app.pixelColor.rgba(color2_r, color2_g, color2_b, color2_a)
end

-- Floyd-Steinberg error diffusion
app.transaction(function()
-- Create error buffer (width+2 to handle edges, 2 rows for current and next)
local errors = {}
for row = 0, 1 do
errors[row] = {}
for col = 0, %d + 1 do
errors[row][col] = {r=0, g=0, b=0}
end
end

for py = 0, %d - 1 do
-- Swap error buffers for next row
if py > 0 then
errors[0], errors[1] = errors[1], errors[0]
-- Clear the new "next row" buffer
for col = 0, %d + 1 do
errors[1][col] = {r=0, g=0, b=0}
end
end

for px = 0, %d - 1 do
-- Calculate gradient position (0.0 to 1.0 across width)
local gradient_pos = px / (%d - 1)
Comment thread
willibrandon marked this conversation as resolved.
Outdated

-- Calculate ideal gradient color at this position
local ideal_r = color1_r + (color2_r - color1_r) * gradient_pos
local ideal_g = color1_g + (color2_g - color1_g) * gradient_pos
local ideal_b = color1_b + (color2_b - color1_b) * gradient_pos

-- Add accumulated error
local err = errors[0][px + 1]
local new_r = math.max(0, math.min(255, ideal_r + err.r))
local new_g = math.max(0, math.min(255, ideal_g + err.g))
local new_b = math.max(0, math.min(255, ideal_b + err.b))

-- Calculate color with accumulated error
local targetColor
if spr.colorMode == ColorMode.INDEXED then
-- Choose nearest color by Euclidean distance
local dist1 = (new_r - color1_r)^2 + (new_g - color1_g)^2 + (new_b - color1_b)^2
local dist2 = (new_r - color2_r)^2 + (new_g - color2_g)^2 + (new_b - color2_b)^2

targetColor = (dist1 < dist2) and color1 or color2

-- Calculate error in RGB space
local actual_r, actual_g, actual_b
if targetColor == color1 then
actual_r, actual_g, actual_b = color1_r, color1_g, color1_b
else
actual_r, actual_g, actual_b = color2_r, color2_g, color2_b
end

local err_r = new_r - actual_r
local err_g = new_g - actual_g
local err_b = new_b - actual_b

-- Distribute error to neighbors (Floyd-Steinberg weights)
if px < %d - 1 then
errors[0][px + 2].r = errors[0][px + 2].r + err_r * 7/16
errors[0][px + 2].g = errors[0][px + 2].g + err_g * 7/16
errors[0][px + 2].b = errors[0][px + 2].b + err_b * 7/16
end
if py < %d - 1 then
if px > 0 then
errors[1][px].r = errors[1][px].r + err_r * 3/16
errors[1][px].g = errors[1][px].g + err_g * 3/16
errors[1][px].b = errors[1][px].b + err_b * 3/16
end
errors[1][px + 1].r = errors[1][px + 1].r + err_r * 5/16
errors[1][px + 1].g = errors[1][px + 1].g + err_g * 5/16
errors[1][px + 1].b = errors[1][px + 1].b + err_b * 5/16
if px < %d - 1 then
errors[1][px + 2].r = errors[1][px + 2].r + err_r * 1/16
errors[1][px + 2].g = errors[1][px + 2].g + err_g * 1/16
errors[1][px + 2].b = errors[1][px + 2].b + err_b * 1/16
end
end
else
-- RGB mode - choose nearest color by Euclidean distance
local dist1 = (new_r - color1_r)^2 + (new_g - color1_g)^2 + (new_b - color1_b)^2
local dist2 = (new_r - color2_r)^2 + (new_g - color2_g)^2 + (new_b - color2_b)^2

targetColor = (dist1 < dist2) and color1 or color2

-- Calculate error (difference between gradient+error and quantized color)
local actual_r = app.pixelColor.rgbaR(targetColor)
local actual_g = app.pixelColor.rgbaG(targetColor)
local actual_b = app.pixelColor.rgbaB(targetColor)

local err_r = new_r - actual_r
local err_g = new_g - actual_g
local err_b = new_b - actual_b

-- Distribute error to neighbors
if px < %d - 1 then
errors[0][px + 2].r = errors[0][px + 2].r + err_r * 7/16
errors[0][px + 2].g = errors[0][px + 2].g + err_g * 7/16
errors[0][px + 2].b = errors[0][px + 2].b + err_b * 7/16
end
if py < %d - 1 then
if px > 0 then
errors[1][px].r = errors[1][px].r + err_r * 3/16
errors[1][px].g = errors[1][px].g + err_g * 3/16
errors[1][px].b = errors[1][px].b + err_b * 3/16
end
errors[1][px + 1].r = errors[1][px + 1].r + err_r * 5/16
errors[1][px + 1].g = errors[1][px + 1].g + err_g * 5/16
errors[1][px + 1].b = errors[1][px + 1].b + err_b * 5/16
if px < %d - 1 then
errors[1][px + 2].r = errors[1][px + 2].r + err_r * 1/16
errors[1][px + 2].g = errors[1][px + 2].g + err_g * 1/16
errors[1][px + 2].b = errors[1][px + 2].b + err_b * 1/16
end
end
end

img:drawPixel(%d + px, %d + py, targetColor)
end
end
end)

spr:saveAs(spr.filename)
print("Dithering applied successfully")`,
layerName, layerName,
frameNumber, frameNumber,
c1.R, c1.G, c1.B, c1.A,
c2.R, c2.G, c2.B, c2.A,
width, // line 914: error buffer width
height, // line 919: py loop
width, // line 924: clear buffer width
width, // line 929: px loop
width, // line 931: gradient calculation
width, // line 966: right neighbor check
height, // line 971: bottom neighbor check
width, // line 980: bottom-right check
width, // line 1006: right neighbor check (RGB)
height, // line 1011: bottom neighbor check (RGB)
width, // line 1020: bottom-right check (RGB)
x, // line 1028: x coordinate
y) // line 1028: y coordinate
}
7 changes: 4 additions & 3 deletions pkg/tools/dithering.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ type DrawWithDitherInput struct {
Region RegionInput `json:"region" jsonschema:"Rectangular region to fill with dithering"`
Color1 string `json:"color1" jsonschema:"First color (hex #RRGGBB or #RRGGBBAA)"`
Color2 string `json:"color2" jsonschema:"Second color (hex #RRGGBB or #RRGGBBAA)"`
Pattern string `json:"pattern" jsonschema:"Dithering pattern: bayer_2x2|bayer_4x4|bayer_8x8|checkerboard|grass|water|stone|cloud|brick|dots|diagonal|cross|noise|horizontal_lines|vertical_lines"`
Pattern string `json:"pattern" jsonschema:"Dithering pattern: bayer_2x2|bayer_4x4|bayer_8x8|checkerboard|floyd_steinberg|grass|water|stone|cloud|brick|dots|diagonal|cross|noise|horizontal_lines|vertical_lines"`
Density float64 `json:"density,omitempty" jsonschema:"Ratio of color1 to color2 (0.0-1.0, default: 0.5)"`
}

Expand All @@ -37,7 +37,7 @@ func RegisterDitheringTools(server *mcp.Server, client *aseprite.Client, gen *as
server,
&mcp.Tool{
Name: "draw_with_dither",
Description: "Fill a region with a dithering pattern to create smooth gradients and textures. Supports 15 patterns: Bayer matrix (bayer_2x2, bayer_4x4, bayer_8x8) for ordered dithering, checkerboard for 50/50 blends, and texture patterns (grass, water, stone, cloud, brick, dots, diagonal, cross, noise, horizontal_lines, vertical_lines) for organic effects. Use density parameter to control the ratio of color1 to color2 (0.0 = all color1, 1.0 = all color2, 0.5 = even mix). Essential for professional pixel art gradients and textures.",
Description: "Fill a region with a dithering pattern to create smooth gradients and textures. Supports 16 patterns: Bayer matrix (bayer_2x2, bayer_4x4, bayer_8x8) for ordered dithering, Floyd-Steinberg error diffusion (floyd_steinberg) for high-quality gradients, checkerboard for 50/50 blends, and texture patterns (grass, water, stone, cloud, brick, dots, diagonal, cross, noise, horizontal_lines, vertical_lines) for organic effects. Use density parameter to control the ratio of color1 to color2 (0.0 = all color1, 1.0 = all color2, 0.5 = even mix). Essential for professional pixel art gradients and textures.",
},
maybeWrapWithTiming("draw_with_dither", logger, cfg.EnableTiming, func(ctx context.Context, req *mcp.CallToolRequest, input DrawWithDitherInput) (*mcp.CallToolResult, *struct{ Success bool }, error) {
opLogger := logger.WithContext(ctx)
Expand Down Expand Up @@ -79,9 +79,10 @@ func RegisterDitheringTools(server *mcp.Server, client *aseprite.Client, gen *as
"noise": true,
"horizontal_lines": true,
"vertical_lines": true,
"floyd_steinberg": true,
}
if !validPatterns[input.Pattern] {
return nil, nil, fmt.Errorf("invalid pattern: %s (must be one of: bayer_2x2, bayer_4x4, bayer_8x8, checkerboard, grass, water, stone, cloud, brick, dots, diagonal, cross, noise, horizontal_lines, vertical_lines)", input.Pattern)
return nil, nil, fmt.Errorf("invalid pattern: %s (must be one of: bayer_2x2, bayer_4x4, bayer_8x8, checkerboard, grass, water, stone, cloud, brick, dots, diagonal, cross, noise, horizontal_lines, vertical_lines, floyd_steinberg)", input.Pattern)
}

// Validate colors (basic hex format check)
Expand Down
Loading