Skip to content

fix(indexed): stop transparent pixels reverting to opaque black#24

Open
Fuitad wants to merge 6 commits into
willibrandon:mainfrom
Fuitad:fix/indexed-transparent-background
Open

fix(indexed): stop transparent pixels reverting to opaque black#24
Fuitad wants to merge 6 commits into
willibrandon:mainfrom
Fuitad:fix/indexed-transparent-background

Conversation

@Fuitad

@Fuitad Fuitad commented Jul 3, 2026

Copy link
Copy Markdown

Summary

On indexed sprites CreateCanvas sets Sprite.transparentColor = 255 so palette index 0 can hold a real color. That makes index 0 an opaque color, and two code paths left transparent areas showing that opaque index-0 background (black on a DawnBringer 32 palette):

draw_pixels (reported bug). The tool normalizes the target cel into a full-canvas Image(spr.width, spr.height, spr.colorMode), which Aseprite fills with index 0. Compositing the existing content with the default NORMAL blend (full:drawImage(cel.image, cel.position)) skips the source's transparent (index 255) pixels, so every pixel a previous draw_pixels call made transparent, but the current call does not rewrite, silently flips to opaque black. In practice this corrupts already-transparent regions whenever a sprite is drawn across multiple calls.

Fixed by clearing the full-canvas image to spr.transparentColor before compositing/drawing, in both the existing-cel and new-cel paths.

create_canvas (related). The initial cel of a new indexed sprite is filled with index 0, so a freshly created indexed canvas starts with an opaque background instead of transparent. Fixed by clearing the initial cel to spr.transparentColor right after creation.

RGB and grayscale behavior is unchanged; both fixes are guarded to indexed mode.

Root cause

Image() initializes indexed pixels to index 0, but on these sprites index 255 is the transparent index. Anywhere the code builds a fresh full-canvas image and treats it as empty/transparent, the background is actually opaque index 0, and NORMAL-blend drawImage then preserves that opaque background wherever the source is transparent.

Tests

  • Behavioral integration tests (real Aseprite) for both cases: a later draw_pixels call preserving previously-transparent pixels, and a fresh indexed canvas reading back transparent. Both fail on the pre-fix code (transparent pixels read #000000FF) and pass after.
  • CI-safe unit string checks guarding the generated Lua for both fixes.

Note on base

This builds on the draw_pixels cel-normalization work (#20 / #21): the full-canvas compositing that this corrects is not on main yet, so until those merge this PR's diff shows those commits stacked underneath. The net new change here is the two spr.transparentColor clears plus their tests.

Christopher Hanson and others added 6 commits June 26, 2026 18:54
draw_pixels wrote to cel.image via Image:putPixel using sprite
coordinates, but putPixel expects cel-LOCAL coordinates. Once a layer's
cel is trimmed to a non-(0,0) origin (e.g. after a shape tool runs),
every pixel was offset by the cel's position, and pixels falling outside
the cel's bounds were dropped entirely. The shape tools are unaffected
because they use app.useTool, which operates in sprite space.

Normalize the target cel to a full-canvas image at (0,0) before writing
(compositing any existing content first), so sprite coordinates equal
image coordinates. This also gives a freshly created cel a valid image,
fixing draw_pixels on newly added layers. Aseprite re-trims the cel on
save, so canvas dimensions and file size are unchanged.

Tests:
- pkg/aseprite/lua_test.go: unit assertion that the generated script
  normalizes the cel to full-canvas at (0,0). Runs without Aseprite, so
  it guards the fix in CI, where the integration tests do not run.
- pkg/tools: integration tests for the offset-after-shape case and for
  an asymmetric cel origin with a pixel outside the shape's bounds (the
  lost-pixel case). Both fail on stock and pass with this change.
…ette color

Palette:resize silently clamps Sprite.transparentColor into the new
palette's valid index range. After set_palette, quantize_palette,
add_palette_color, or apply_auto_shading resize an indexed sprite's
palette, transparentColor can land on the same index as a real,
in-use color (typically the palette's last entry).

Once that happens, painting "transparent" (fill_area with a fully
transparent color) writes the colliding index, so background fills
read back as an opaque real color instead of transparent, and pixels
intentionally drawn in that color can be auto-trimmed away as if they
were transparent - the same class of bug fixed for index 0 in
b5175b8, reintroduced at index N-1.

Fix: re-assert transparentColor = 255 after every palette resize on
an indexed sprite via a shared ReassertIndexedTransparentColor()
helper, keeping it out of the palette's real, addressable range.

Adds a regression test reproducing the exact collision and verifying
both the drawn color and the transparent fill read back correctly.
Root cause: on an indexed sprite, img:putPixel and app.useTool both
expect a palette index, not an arbitrary RGBA value. Passing a Color
object directly resolves correctly when the sprite's palette was set
earlier in the SAME Aseprite process, but pixel-mcp runs each tool
call as its own `aseprite --batch` invocation: the palette is set in
one process and drawing happens in the next. In that (normal, for
every real tool call) cross-process scenario, Aseprite's own
Color-to-index resolution for img:putPixel/app.useTool does not
reliably match the sprite's actual, correctly-loaded palette (verified
by reading spr.palettes[1] back at the point of failure) and silently
resolves to an unrelated index instead, corrupting the drawn color
with no error.

Affects draw_pixels, draw_line, draw_contour, draw_rectangle,
draw_circle, and fill_area whenever use_palette is false (the default)
on an indexed sprite.

Fix: resolveExactPaletteColor (pkg/aseprite/lua_core.go) resolves the
color itself by reading the sprite's actual active palette at Lua
runtime, bypassing Aseprite's unreliable resolution entirely.
- Fully-transparent colors (alpha 0, e.g. a "paint transparent"
  fill_area call) resolve directly to spr.transparentColor, since
  transparency is a designated index, not a color expected to exist
  in the palette.
- Any other color requires an exact match in the active palette and
  errors if none exists, so a caller drawing an approximate/non-locked
  color silently gets the wrong pixel instead of an error telling them
  to pass use_palette=true to snap to the nearest palette color.

Wired into DrawPixels (FormatColorWithPalette) and DrawLine/
DrawContour/DrawRectangle/DrawCircle/FillArea (FormatColorWithPaletteForTool).

Testing:
- go test ./...: 628 passed
- go test -tags=integration ./...: 805 passed
- Added TestIntegration_IndexedMode_ExactColorResolution
  (pkg/tools/exact_palette_color_indexed_mode_bug_test.go): exact match
  via draw_rectangle, exact match via draw_pixels, and error on no
  match. Verified RED without the fix (all three fail/misbehave
  exactly as described above) and GREEN with it.
- Updated two pre-existing tests that asserted the old, buggy
  behavior: TestFormatColorWithPalette_NoPalette and
  TestLuaGenerator_DrawPixels now expect resolveExactPaletteColor(...)
  instead of a raw Color(...) literal.
- Fixed TestIntegration_SetPalette_ThenApplyShading, which drew an
  approximate "mid-tone" color with use_palette=false even though that
  color was never one of the locked palette's 16 exact entries; it now
  correctly passes use_palette=true to snap it.
MedianCutQuantization split each bucket at the pixel-array midpoint
(mid := len(pixels) / 2), not at a boundary between distinct colors.
Pixel art typically has wildly imbalanced color frequency (a large
solid fill vastly outnumbering a 1-2px accent detail), so splitting
at the raw pixel-count median lands the cut inside the dominant
color's own run instead of between colors.

The dominant color got needlessly re-subdivided into near-duplicate
buckets while rare accent colors never got isolated into their own
bucket, and were instead averaged into a blended, off-palette hex
value - even when target_colors comfortably exceeded the number of
unique colors actually present, which should guarantee lossless
recovery. On a 32x32 sprite with a ~200px tunic fill, ~90px skin
fill, a thin outline, and 2px highlight/buckle accents, quantizing
to target_colors=32 (13 unique colors present) returned colors like
#373130 that did not exist anywhere in the source image.

Fix: reduce to a frequency-weighted histogram of unique colors
before bucketing, so a split always falls between distinct colors.
The split point still favors common colors over rare ones by
cumulative pixel count, preserving normal median-cut reduction
behavior when target_colors is genuinely smaller than the number of
unique colors.

I initially suspected a second bug in ApplyQuantizedPalette/
ChangePixelFormat (pixels resolving to the wrong palette index,
transparency lost on conversion to indexed mode). Verified via
aseprite --batch scripts and full end-to-end integration tests
(Go clustering -> Lua apply -> get_pixels) that ChangePixelFormat
correctly honors a palette set immediately before it runs, in both
the lossless (target_colors >= unique colors) and genuine lossy
reduction cases. The apparent scrambling in my initial hand-written
repro script was caused by omitting the existing
ReassertIndexedTransparentColor() call, not a new bug - so no change
was needed there.

Testing:
- go test ./...: 633 passed
- go test -tags=integration ./...: unchanged pre-existing suite passed,
  plus the new regression test
- Added TestMedianCutQuantization_ImbalancedFrequencyPreservesExactColors
  (pkg/aseprite/quantization_test.go): pure-Go unit test, no Aseprite
  needed, guards the fix in CI
- Added TestIntegration_QuantizePalette_ImbalancedFrequencyPreservesExactColors
  (pkg/tools/quantize_palette_imbalanced_frequency_bug_test.go):
  reproduces the exact hero-sprite shape end to end through the real
  quantize_palette pipeline (export -> Go quantize -> Lua apply ->
  get_pixels), checking both exact colors and transparency survive
  conversion to indexed mode. Verified RED without the fix (blended
  colors and wrong pixels, matching the originally observed
  corruption) and GREEN with it.
On indexed sprites CreateCanvas sets transparentColor = 255 so palette index 0
can hold a real color, which makes index 0 an opaque color. Two paths left
transparent areas showing that opaque index-0 background:

- draw_pixels normalizes the target cel into a full-canvas Image() (filled with
  index 0) and composites the existing content with the default NORMAL blend,
  which skips the source's transparent (index 255) pixels. Any pixel a previous
  call had made transparent but the current call does not rewrite flipped to
  opaque black. Fixed by clearing the full-canvas image to spr.transparentColor
  before compositing/drawing, in both the existing-cel and new-cel paths.

- create_canvas left the initial cel filled with index 0, so a freshly created
  indexed canvas started with an opaque background. Fixed by clearing the initial
  cel to spr.transparentColor right after creation.

RGB and grayscale behavior is unchanged (both guarded to indexed mode). Adds
behavioral integration tests that reproduce both cases and CI-safe string checks.
@Fuitad

Fuitad commented Jul 20, 2026

Copy link
Copy Markdown
Author

Revalidated on 2026-07-20 against Aseprite 1.3.17.2.

  • go test ./...: 631 passed
  • go test -tags=integration ./...: 811 passed
  • Downstream indexed Aseprite resource decoding now preserves transparent backgrounds across canvas creation, palette assignment, repeated draw_pixels calls, nearest-neighbor scaling, save, and reload.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant