From 52efb4f0ae97f20102201d3a3418bbac0106ce49 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Thu, 15 Oct 2026 07:52:26 +0000 Subject: [PATCH 1/2] 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 Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J --- CHANGELOG.md | 2 ++ README.md | 3 ++- src/image.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 2 +- tests/regressions.js | 14 ++++++++++++++ 5 files changed, 62 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65260918..2bc7b700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index f67e14d1..b8d92bd1 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/src/image.ts b/src/image.ts index 71730d9e..7adde87a 100644 --- a/src/image.ts +++ b/src/image.ts @@ -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 */ @@ -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 diff --git a/src/index.ts b/src/index.ts index 37ca354d..84de7b8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/tests/regressions.js b/tests/regressions.js index bfb9f9b9..011e25cf 100644 --- a/tests/regressions.js +++ b/tests/regressions.js @@ -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'); From 378b4a5019e0827a7b85b5f2ba86a13c4379cd11 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Thu, 15 Oct 2026 08:23:08 +0000 Subject: [PATCH 2/2] document that colorToAlpha removes the color everywhere Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J --- CHANGELOG.md | 1 + src/image.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bc7b700..06125067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ 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. + The color is removed everywhere in the image, not only from a connected background. ### Deprecated diff --git a/src/image.ts b/src/image.ts index 7adde87a..2e3ae68e 100644 --- a/src/image.ts +++ b/src/image.ts @@ -1181,7 +1181,9 @@ export class Image { /** * 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 + * semi-transparent instead of keeping a fringe of it. + * The color is removed everywhere in the image, not only from a connected background: removing a white + * background also makes white areas inside the subject (e.g. eyes in a logo) transparent * @param color The color to remove, by default the color of the top left pixel (e.g. a background) * @param options See {@link ColorToAlphaOptions} */