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
Next Next commit
colorToAlpha (#39)
Works like GIMP's "Color to Alpha": the color is unmixed from every pixel,
so compositing the result over that color gives the original back and
antialiased edges keep no fringe of it. Thresholds make colors close to it
fully transparent or keep distant ones fully opaque; without a color, the
top left pixel's is used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J
  • Loading branch information
matmen and claude committed Oct 15, 2026
commit 52efb4f0ae97f20102201d3a3418bbac0106ce49
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
- **TGA** decoding and encoding (#21): color mapped, truecolor and greyscale, uncompressed and run length encoded, all
four origins. `Image.decode()` recognizes TGAs by their header (they have no magic number), `encodeTGA()` writes
run length encoded 24/32 bit files.
- **`colorToAlpha(color, options)`** (#39): makes a color transparent like GIMP's "Color to Alpha", antialiased edges
become translucent instead of keeping a fringe of the removed color. Without a color, the top left pixel's is used.

### Deprecated

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
, [sharpen](https://imagescript.matmen.dev/classes/Image.html#sharpen)
, [brightness](https://imagescript.matmen.dev/classes/Image.html#brightness)
, [hueShift](https://imagescript.matmen.dev/classes/Image.html#hueshift), ...)
- Compositing with opacity and blend modes ([composite](https://imagescript.matmen.dev/classes/Image.html#composite))
- Compositing with opacity and blend modes ([composite](https://imagescript.matmen.dev/classes/Image.html#composite)),
removing backgrounds ([colorToAlpha](https://imagescript.matmen.dev/classes/Image.html#colortoalpha))
- Color information functions ([averageColor](https://imagescript.matmen.dev/classes/Image.html#averagecolor)
, [dominantColor](https://imagescript.matmen.dev/classes/Image.html#dominantcolor), ...)
- Encoding images as [PNGs](https://imagescript.matmen.dev/classes/Image.html#encode) (smallest lossless color type)
Expand Down
43 changes: 43 additions & 0 deletions src/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ export interface RotateOptions {
resize?: boolean;
}

/** Options of {@link Image.colorToAlpha} */
export interface ColorToAlphaOptions {
/** Pixels at most this far from the color (0-1) become fully transparent. Default: 0 */
transparencyThreshold?: number;
/** Pixels at least this far from the color (0-1) stay fully opaque. Default: 1 */
opacityThreshold?: number;
}

/** Text metadata written into PNGs by {@link Image.encode} */
export interface PNGMetadata {
/** The image's title */
Expand Down Expand Up @@ -1170,6 +1178,41 @@ export class Image {
return this;
}

/**
* Makes a color transparent, like GIMP's "Color to Alpha": the color is removed from every pixel and replaced by
* transparency, so pixels that are partly the color (e.g. antialiased edges against a background) become
* semi-transparent instead of keeping a fringe of it
* @param color The color to remove, by default the color of the top left pixel (e.g. a background)
* @param options See {@link ColorToAlphaOptions}
*/
colorToAlpha(color?: Color, options: ColorToAlphaOptions = {}): this {
const { transparencyThreshold = 0, opacityThreshold = 1 } = options;
if (!(transparencyThreshold >= 0 && opacityThreshold <= 1 && transparencyThreshold < opacityThreshold))
throw new RangeError('Invalid thresholds, they have to satisfy 0 <= transparencyThreshold < opacityThreshold <= 1');

const bitmap = this.bitmap;
const c = color === undefined ? [bitmap[0], bitmap[1], bitmap[2]] : [color >>> 24, color >> 16 & 255, color >> 8 & 255];

for (let i = 0; i < bitmap.length; i += 4) {
// how far the pixel is from the color: the alpha it needs to be "pixel = alpha * X + (1 - alpha) * color"
let distance = 0;
for (let k = 0; k < 3; k++) {
const d = bitmap[i + k] - c[k];
const a = d > 0 ? d / (255 - c[k]) : d < 0 ? -d / c[k] : 0;
if (a > distance) distance = a;
}

const alpha = distance <= transparencyThreshold ? 0 : distance >= opacityThreshold ? 1
: (distance - transparencyThreshold) / (opacityThreshold - transparencyThreshold);

// unmix the color, so it looks the same over the removed color
if (alpha > 0) for (let k = 0; k < 3; k++) bitmap[i + k] = Math.round((bitmap[i + k] - c[k]) / alpha + c[k]);
bitmap[i + 3] = Math.round(bitmap[i + 3] * alpha);
}

return this;
}

/**
* Maps every color channel value through a function (evaluated once per value 0..255), alpha is kept
* @internal
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { view } from './util/mem.ts';
import { ImageType } from './image-type.ts';

export { Image } from './image.ts';
export type { Color, ColorFunction, ResizeMode, SVGScaleMode, PNGMetadata, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions } from './image.ts';
export type { Color, ColorFunction, ResizeMode, SVGScaleMode, PNGMetadata, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions } from './image.ts';
export { Frame } from './frame.ts';
export type { FrameDisposalModeName, FrameDisposalModeId } from './frame.ts';
export { GIF } from './gif.ts';
Expand Down
14 changes: 14 additions & 0 deletions tests/regressions.js
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,20 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y));
assert(both.width === 3 && rgba(both, 2, 1).slice(0, 2).join() === '0,0', 'PNG orientation: eXIf should win over XMP');
}

// colorToAlpha removes the color, so compositing the result over that color gives the original back (#39)
{
const image = new Image(40, 40).fill(0xffffffff).drawCircle(20, 20, 12, 0xff0000ff);
const cut = image.clone().colorToAlpha();
assert(cut.getPixelAt(0, 0) === 0xffffff00, 'colorToAlpha: the background should become transparent');
assert(cut.getPixelAt(20, 20) === 0xff0000ff, 'colorToAlpha: other colors should stay opaque');
const back = new Image(40, 40).fill(0xffffffff).composite(cut);
assert(Buffer.from(back.bitmap).equals(Buffer.from(image.bitmap)), 'colorToAlpha: compositing over the color should give the original');
const edge = cut.getRGBAAt(32, 20);
assert(edge[3] > 0 && edge[3] < 255 && edge[0] === 255 && edge[1] === 0, 'colorToAlpha: antialiased edges should become translucent red');
const loose = image.clone().colorToAlpha(0xffffffff, {transparencyThreshold: 0.5});
assert(loose.getRGBAAt(32, 20)[3] < edge[3], 'colorToAlpha: the threshold should make more of the edge transparent');
}

// slice-by-16 crc32 matches zlib for every length around the 16 byte steps and for unaligned views (#12)
{
const {crc32} = await import('../dist/codecs/crc.js');
Expand Down