diff --git a/CHANGELOG.md b/CHANGELOG.md index 65260918..06125067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ - **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. + The color is removed everywhere in the image, not only from a connected background. ### 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..2e3ae68e 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,43 @@ 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. + * 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} + */ + 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');