Skip to content

Commit b62bb37

Browse files
authored
Merge pull request #6 from willibrandon/feature/floyd-steinberg-dithering
Add Floyd-Steinberg error diffusion dithering Implements Floyd-Steinberg error diffusion as the 16th dithering pattern. The algorithm creates smooth gradients by distributing quantization errors to neighboring pixels using the standard 7/16, 5/16, 3/16, 1/16 weight distribution. Implementation details: - Generates horizontal gradients from color1 to color2 - Uses Euclidean distance for color selection - Handles edge case where width=1 to prevent division by zero - Density parameter unused (standard Floyd-Steinberg has no density control) - Includes pixel verification test confirming gradient output All tests pass including new gradient verification test. Fixes #5
2 parents 0050ac6 + d458dc3 commit b62bb37

4 files changed

Lines changed: 413 additions & 11 deletions

File tree

examples/client/main.go

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -246,9 +246,9 @@ func createAnimatedSprite(ctx context.Context, session *mcp.ClientSession, logge
246246

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

267-
// Apply dithering with Bayer 4x4 pattern
267+
// Apply Bayer 4x4 dithering to left half
268268
logger.Information("")
269-
logger.Information("Step 10: Applying Bayer 4x4 dithering pattern...")
269+
logger.Information("Step 10: Applying Bayer 4x4 pattern (left half)...")
270270
if _, err := callTool(ctx, session, "draw_with_dither", map[string]any{
271271
"sprite_path": ditherSprite,
272272
"layer_name": "Layer 1",
@@ -282,14 +282,35 @@ func createAnimatedSprite(ctx context.Context, session *mcp.ClientSession, logge
282282
"pattern": "bayer_4x4",
283283
"density": 0.5,
284284
}); err != nil {
285-
return fmt.Errorf("draw_with_dither failed: %w", err)
285+
return fmt.Errorf("draw_with_dither (bayer) failed: %w", err)
286+
}
287+
logger.Information(" Bayer 4x4 pattern applied (ordered dithering)")
288+
289+
// Apply Floyd-Steinberg dithering to right half
290+
logger.Information(" Applying Floyd-Steinberg pattern (right half)...")
291+
if _, err := callTool(ctx, session, "draw_with_dither", map[string]any{
292+
"sprite_path": ditherSprite,
293+
"layer_name": "Layer 1",
294+
"frame_number": 1,
295+
"region": map[string]any{
296+
"x": 64,
297+
"y": 0,
298+
"width": 64,
299+
"height": 64,
300+
},
301+
"color1": "#001F3F",
302+
"color2": "#7FDBFF",
303+
"pattern": "floyd_steinberg",
304+
"density": 0.5,
305+
}); err != nil {
306+
return fmt.Errorf("draw_with_dither (floyd-steinberg) failed: %w", err)
286307
}
287-
logger.Information(" Dithering applied successfully")
308+
logger.Information(" Floyd-Steinberg pattern applied (error diffusion)")
288309

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

303325
// Step 12: Analyze palette harmonies
304326
logger.Information("")

pkg/aseprite/lua_drawing.go

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,9 @@ local matrixSize = 4`
694694
{1, 0, 1, 0}
695695
}
696696
local matrixSize = 4`
697+
case "floyd_steinberg":
698+
// Floyd-Steinberg uses error diffusion instead of matrix patterns
699+
return generateFloydSteinbergLua(escapedLayerName, frameNumber, x, y, width, height, c1, c2, density)
697700
default:
698701
return fmt.Sprintf(`error("Unknown dithering pattern: %s")`, pattern)
699702
}
@@ -809,3 +812,245 @@ print("Dithering applied successfully")`,
809812
height, width,
810813
x, y)
811814
}
815+
816+
// generateFloydSteinbergLua generates Lua script for Floyd-Steinberg error diffusion dithering.
817+
//
818+
// Floyd-Steinberg is an error diffusion algorithm that produces high-quality dithering
819+
// by propagating quantization error to neighboring pixels. The error distribution pattern is:
820+
//
821+
// X 7/16
822+
// 3/16 5/16 1/16
823+
//
824+
// where X is the current pixel being processed.
825+
//
826+
// The implementation creates a horizontal gradient from color1 to color2 and applies error
827+
// diffusion to produce smooth transitions. The density parameter is currently unused as the
828+
// gradient quality is controlled by the error diffusion algorithm itself.
829+
func generateFloydSteinbergLua(layerName string, frameNumber int, x, y, width, height int, c1, c2 Color, density float64) string {
830+
return fmt.Sprintf(`local spr = app.activeSprite
831+
if not spr then
832+
error("No active sprite")
833+
end
834+
835+
-- Find layer
836+
local layer = nil
837+
for _, l in ipairs(spr.layers) do
838+
if l.name == "%s" then
839+
layer = l
840+
break
841+
end
842+
end
843+
844+
if not layer then
845+
error("Layer not found: %s")
846+
end
847+
848+
-- Get frame
849+
local frame = spr.frames[%d]
850+
if not frame then
851+
error("Frame not found: %d")
852+
end
853+
854+
-- Get or create cel
855+
local cel = layer:cel(frame)
856+
if not cel then
857+
cel = spr:newCel(layer, frame)
858+
end
859+
860+
-- Create or get image
861+
local img = cel.image
862+
if not img then
863+
img = Image(spr.width, spr.height, spr.colorMode)
864+
cel.image = img
865+
end
866+
867+
-- Helper: Find nearest palette index for given RGBA color
868+
local function findNearestPaletteIndex(r, g, b, a)
869+
local palette = spr.palettes[1]
870+
if not palette or #palette == 0 then
871+
return 0
872+
end
873+
874+
local minDist = math.huge
875+
local nearestIndex = 0
876+
877+
for i = 0, #palette - 1 do
878+
local palColor = palette:getColor(i)
879+
local dr = r - palColor.red
880+
local dg = g - palColor.green
881+
local db = b - palColor.blue
882+
local da = a - palColor.alpha
883+
local dist = dr*dr + dg*dg + db*db + da*da
884+
885+
if dist < minDist then
886+
minDist = dist
887+
nearestIndex = i
888+
end
889+
end
890+
891+
return nearestIndex
892+
end
893+
894+
-- Define colors based on color mode
895+
local color1, color2
896+
local color1_r, color1_g, color1_b, color1_a = %d, %d, %d, %d
897+
local color2_r, color2_g, color2_b, color2_a = %d, %d, %d, %d
898+
899+
if spr.colorMode == ColorMode.INDEXED then
900+
-- In indexed mode, img:drawPixel expects palette indices
901+
color1 = findNearestPaletteIndex(color1_r, color1_g, color1_b, color1_a)
902+
color2 = findNearestPaletteIndex(color2_r, color2_g, color2_b, color2_a)
903+
else
904+
-- In RGB/Grayscale mode, use pixel color values
905+
color1 = app.pixelColor.rgba(color1_r, color1_g, color1_b, color1_a)
906+
color2 = app.pixelColor.rgba(color2_r, color2_g, color2_b, color2_a)
907+
end
908+
909+
-- Floyd-Steinberg error diffusion
910+
app.transaction(function()
911+
-- Create error buffer (width+2 to handle edges, 2 rows for current and next)
912+
local errors = {}
913+
for row = 0, 1 do
914+
errors[row] = {}
915+
for col = 0, %d + 1 do
916+
errors[row][col] = {r=0, g=0, b=0}
917+
end
918+
end
919+
920+
for py = 0, %d - 1 do
921+
-- Swap error buffers for next row
922+
if py > 0 then
923+
errors[0], errors[1] = errors[1], errors[0]
924+
-- Clear the new "next row" buffer
925+
for col = 0, %d + 1 do
926+
errors[1][col] = {r=0, g=0, b=0}
927+
end
928+
end
929+
930+
for px = 0, %d - 1 do
931+
-- Calculate gradient position (0.0 to 1.0 across width)
932+
local gradient_pos
933+
if %d == 1 then
934+
gradient_pos = 0
935+
else
936+
gradient_pos = px / (%d - 1)
937+
end
938+
939+
-- Calculate ideal gradient color at this position
940+
local ideal_r = color1_r + (color2_r - color1_r) * gradient_pos
941+
local ideal_g = color1_g + (color2_g - color1_g) * gradient_pos
942+
local ideal_b = color1_b + (color2_b - color1_b) * gradient_pos
943+
944+
-- Add accumulated error
945+
local err = errors[0][px + 1]
946+
local new_r = math.max(0, math.min(255, ideal_r + err.r))
947+
local new_g = math.max(0, math.min(255, ideal_g + err.g))
948+
local new_b = math.max(0, math.min(255, ideal_b + err.b))
949+
950+
-- Calculate color with accumulated error
951+
local targetColor
952+
if spr.colorMode == ColorMode.INDEXED then
953+
-- Choose nearest color by Euclidean distance
954+
local dist1 = (new_r - color1_r)^2 + (new_g - color1_g)^2 + (new_b - color1_b)^2
955+
local dist2 = (new_r - color2_r)^2 + (new_g - color2_g)^2 + (new_b - color2_b)^2
956+
957+
targetColor = (dist1 < dist2) and color1 or color2
958+
959+
-- Calculate error in RGB space
960+
local actual_r, actual_g, actual_b
961+
if targetColor == color1 then
962+
actual_r, actual_g, actual_b = color1_r, color1_g, color1_b
963+
else
964+
actual_r, actual_g, actual_b = color2_r, color2_g, color2_b
965+
end
966+
967+
local err_r = new_r - actual_r
968+
local err_g = new_g - actual_g
969+
local err_b = new_b - actual_b
970+
971+
-- Distribute error to neighbors (Floyd-Steinberg weights)
972+
if px < %d - 1 then
973+
errors[0][px + 2].r = errors[0][px + 2].r + err_r * 7/16
974+
errors[0][px + 2].g = errors[0][px + 2].g + err_g * 7/16
975+
errors[0][px + 2].b = errors[0][px + 2].b + err_b * 7/16
976+
end
977+
if py < %d - 1 then
978+
if px > 0 then
979+
errors[1][px].r = errors[1][px].r + err_r * 3/16
980+
errors[1][px].g = errors[1][px].g + err_g * 3/16
981+
errors[1][px].b = errors[1][px].b + err_b * 3/16
982+
end
983+
errors[1][px + 1].r = errors[1][px + 1].r + err_r * 5/16
984+
errors[1][px + 1].g = errors[1][px + 1].g + err_g * 5/16
985+
errors[1][px + 1].b = errors[1][px + 1].b + err_b * 5/16
986+
if px < %d - 1 then
987+
errors[1][px + 2].r = errors[1][px + 2].r + err_r * 1/16
988+
errors[1][px + 2].g = errors[1][px + 2].g + err_g * 1/16
989+
errors[1][px + 2].b = errors[1][px + 2].b + err_b * 1/16
990+
end
991+
end
992+
else
993+
-- RGB mode - choose nearest color by Euclidean distance
994+
local dist1 = (new_r - color1_r)^2 + (new_g - color1_g)^2 + (new_b - color1_b)^2
995+
local dist2 = (new_r - color2_r)^2 + (new_g - color2_g)^2 + (new_b - color2_b)^2
996+
997+
targetColor = (dist1 < dist2) and color1 or color2
998+
999+
-- Calculate error (difference between gradient+error and quantized color)
1000+
local actual_r = app.pixelColor.rgbaR(targetColor)
1001+
local actual_g = app.pixelColor.rgbaG(targetColor)
1002+
local actual_b = app.pixelColor.rgbaB(targetColor)
1003+
1004+
local err_r = new_r - actual_r
1005+
local err_g = new_g - actual_g
1006+
local err_b = new_b - actual_b
1007+
1008+
-- Distribute error to neighbors
1009+
if px < %d - 1 then
1010+
errors[0][px + 2].r = errors[0][px + 2].r + err_r * 7/16
1011+
errors[0][px + 2].g = errors[0][px + 2].g + err_g * 7/16
1012+
errors[0][px + 2].b = errors[0][px + 2].b + err_b * 7/16
1013+
end
1014+
if py < %d - 1 then
1015+
if px > 0 then
1016+
errors[1][px].r = errors[1][px].r + err_r * 3/16
1017+
errors[1][px].g = errors[1][px].g + err_g * 3/16
1018+
errors[1][px].b = errors[1][px].b + err_b * 3/16
1019+
end
1020+
errors[1][px + 1].r = errors[1][px + 1].r + err_r * 5/16
1021+
errors[1][px + 1].g = errors[1][px + 1].g + err_g * 5/16
1022+
errors[1][px + 1].b = errors[1][px + 1].b + err_b * 5/16
1023+
if px < %d - 1 then
1024+
errors[1][px + 2].r = errors[1][px + 2].r + err_r * 1/16
1025+
errors[1][px + 2].g = errors[1][px + 2].g + err_g * 1/16
1026+
errors[1][px + 2].b = errors[1][px + 2].b + err_b * 1/16
1027+
end
1028+
end
1029+
end
1030+
1031+
img:drawPixel(%d + px, %d + py, targetColor)
1032+
end
1033+
end
1034+
end)
1035+
1036+
spr:saveAs(spr.filename)
1037+
print("Dithering applied successfully")`,
1038+
layerName, layerName,
1039+
frameNumber, frameNumber,
1040+
c1.R, c1.G, c1.B, c1.A,
1041+
c2.R, c2.G, c2.B, c2.A,
1042+
width, // line 915: error buffer width
1043+
height, // line 920: py loop
1044+
width, // line 925: clear buffer width
1045+
width, // line 930: px loop
1046+
width, // line 933: width check for division by zero
1047+
width, // line 936: gradient calculation
1048+
width, // line 967: right neighbor check
1049+
height, // line 972: bottom neighbor check
1050+
width, // line 981: bottom-right check
1051+
width, // line 1007: right neighbor check (RGB)
1052+
height, // line 1012: bottom neighbor check (RGB)
1053+
width, // line 1021: bottom-right check (RGB)
1054+
x, // line 1031: x coordinate
1055+
y) // line 1031: y coordinate
1056+
}

pkg/tools/dithering.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ type DrawWithDitherInput struct {
1818
Region RegionInput `json:"region" jsonschema:"Rectangular region to fill with dithering"`
1919
Color1 string `json:"color1" jsonschema:"First color (hex #RRGGBB or #RRGGBBAA)"`
2020
Color2 string `json:"color2" jsonschema:"Second color (hex #RRGGBB or #RRGGBBAA)"`
21-
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"`
21+
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"`
2222
Density float64 `json:"density,omitempty" jsonschema:"Ratio of color1 to color2 (0.0-1.0, default: 0.5)"`
2323
}
2424

@@ -37,7 +37,7 @@ func RegisterDitheringTools(server *mcp.Server, client *aseprite.Client, gen *as
3737
server,
3838
&mcp.Tool{
3939
Name: "draw_with_dither",
40-
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.",
40+
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.",
4141
},
4242
maybeWrapWithTiming("draw_with_dither", logger, cfg.EnableTiming, func(ctx context.Context, req *mcp.CallToolRequest, input DrawWithDitherInput) (*mcp.CallToolResult, *struct{ Success bool }, error) {
4343
opLogger := logger.WithContext(ctx)
@@ -79,9 +79,10 @@ func RegisterDitheringTools(server *mcp.Server, client *aseprite.Client, gen *as
7979
"noise": true,
8080
"horizontal_lines": true,
8181
"vertical_lines": true,
82+
"floyd_steinberg": true,
8283
}
8384
if !validPatterns[input.Pattern] {
84-
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)
85+
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)
8586
}
8687

8788
// Validate colors (basic hex format check)

0 commit comments

Comments
 (0)