Skip to content

Commit 35ab788

Browse files
matmenclaude
andcommitted
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
1 parent 46f7fc1 commit 35ab788

5 files changed

Lines changed: 62 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
- **TGA** decoding and encoding (#21): color mapped, truecolor and greyscale, uncompressed and run length encoded, all
1818
four origins. `Image.decode()` recognizes TGAs by their header (they have no magic number), `encodeTGA()` writes
1919
run length encoded 24/32 bit files.
20+
- **`colorToAlpha(color, options)`** (#39): makes a color transparent like GIMP's "Color to Alpha", antialiased edges
21+
become translucent instead of keeping a fringe of the removed color. Without a color, the top left pixel's is used.
2022

2123
### Deprecated
2224

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
4242
, [sharpen](https://imagescript.matmen.dev/classes/Image.html#sharpen)
4343
, [brightness](https://imagescript.matmen.dev/classes/Image.html#brightness)
4444
, [hueShift](https://imagescript.matmen.dev/classes/Image.html#hueshift), ...)
45-
- Compositing with opacity and blend modes ([composite](https://imagescript.matmen.dev/classes/Image.html#composite))
45+
- Compositing with opacity and blend modes ([composite](https://imagescript.matmen.dev/classes/Image.html#composite)),
46+
removing backgrounds ([colorToAlpha](https://imagescript.matmen.dev/classes/Image.html#colortoalpha))
4647
- Color information functions ([averageColor](https://imagescript.matmen.dev/classes/Image.html#averagecolor)
4748
, [dominantColor](https://imagescript.matmen.dev/classes/Image.html#dominantcolor), ...)
4849
- Encoding images as [PNGs](https://imagescript.matmen.dev/classes/Image.html#encode) (smallest lossless color type)

‎src/image.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ export interface RotateOptions {
7575
resize?: boolean;
7676
}
7777

78+
/** Options of {@link Image.colorToAlpha} */
79+
export interface ColorToAlphaOptions {
80+
/** Pixels at most this far from the color (0-1) become fully transparent. Default: 0 */
81+
transparencyThreshold?: number;
82+
/** Pixels at least this far from the color (0-1) stay fully opaque. Default: 1 */
83+
opacityThreshold?: number;
84+
}
85+
7886
/** Text metadata written into PNGs by {@link Image.encode} */
7987
export interface PNGMetadata {
8088
/** The image's title */
@@ -1170,6 +1178,41 @@ export class Image {
11701178
return this;
11711179
}
11721180

1181+
/**
1182+
* Makes a color transparent, like GIMP's "Color to Alpha": the color is removed from every pixel and replaced by
1183+
* transparency, so pixels that are partly the color (e.g. antialiased edges against a background) become
1184+
* semi-transparent instead of keeping a fringe of it
1185+
* @param color The color to remove, by default the color of the top left pixel (e.g. a background)
1186+
* @param options See {@link ColorToAlphaOptions}
1187+
*/
1188+
colorToAlpha(color?: Color, options: ColorToAlphaOptions = {}): this {
1189+
const { transparencyThreshold = 0, opacityThreshold = 1 } = options;
1190+
if (!(transparencyThreshold >= 0 && opacityThreshold <= 1 && transparencyThreshold < opacityThreshold))
1191+
throw new RangeError('Invalid thresholds, they have to satisfy 0 <= transparencyThreshold < opacityThreshold <= 1');
1192+
1193+
const bitmap = this.bitmap;
1194+
const c = color === undefined ? [bitmap[0], bitmap[1], bitmap[2]] : [color >>> 24, color >> 16 & 255, color >> 8 & 255];
1195+
1196+
for (let i = 0; i < bitmap.length; i += 4) {
1197+
// how far the pixel is from the color: the alpha it needs to be "pixel = alpha * X + (1 - alpha) * color"
1198+
let distance = 0;
1199+
for (let k = 0; k < 3; k++) {
1200+
const d = bitmap[i + k] - c[k];
1201+
const a = d > 0 ? d / (255 - c[k]) : d < 0 ? -d / c[k] : 0;
1202+
if (a > distance) distance = a;
1203+
}
1204+
1205+
const alpha = distance <= transparencyThreshold ? 0 : distance >= opacityThreshold ? 1
1206+
: (distance - transparencyThreshold) / (opacityThreshold - transparencyThreshold);
1207+
1208+
// unmix the color, so it looks the same over the removed color
1209+
if (alpha > 0) for (let k = 0; k < 3; k++) bitmap[i + k] = Math.round((bitmap[i + k] - c[k]) / alpha + c[k]);
1210+
bitmap[i + 3] = Math.round(bitmap[i + 3] * alpha);
1211+
}
1212+
1213+
return this;
1214+
}
1215+
11731216
/**
11741217
* Maps every color channel value through a function (evaluated once per value 0..255), alpha is kept
11751218
* @internal

‎src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { view } from './util/mem.ts';
77
import { ImageType } from './image-type.ts';
88

99
export { Image } from './image.ts';
10-
export type { Color, ColorFunction, ResizeMode, SVGScaleMode, PNGMetadata, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions } from './image.ts';
10+
export type { Color, ColorFunction, ResizeMode, SVGScaleMode, PNGMetadata, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions } from './image.ts';
1111
export { Frame } from './frame.ts';
1212
export type { FrameDisposalModeName, FrameDisposalModeId } from './frame.ts';
1313
export { GIF } from './gif.ts';

‎tests/regressions.js‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,20 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y));
494494
assert(both.width === 3 && rgba(both, 2, 1).slice(0, 2).join() === '0,0', 'PNG orientation: eXIf should win over XMP');
495495
}
496496

497+
// colorToAlpha removes the color, so compositing the result over that color gives the original back (#39)
498+
{
499+
const image = new Image(40, 40).fill(0xffffffff).drawCircle(20, 20, 12, 0xff0000ff);
500+
const cut = image.clone().colorToAlpha();
501+
assert(cut.getPixelAt(0, 0) === 0xffffff00, 'colorToAlpha: the background should become transparent');
502+
assert(cut.getPixelAt(20, 20) === 0xff0000ff, 'colorToAlpha: other colors should stay opaque');
503+
const back = new Image(40, 40).fill(0xffffffff).composite(cut);
504+
assert(Buffer.from(back.bitmap).equals(Buffer.from(image.bitmap)), 'colorToAlpha: compositing over the color should give the original');
505+
const edge = cut.getRGBAAt(32, 20);
506+
assert(edge[3] > 0 && edge[3] < 255 && edge[0] === 255 && edge[1] === 0, 'colorToAlpha: antialiased edges should become translucent red');
507+
const loose = image.clone().colorToAlpha(0xffffffff, {transparencyThreshold: 0.5});
508+
assert(loose.getRGBAAt(32, 20)[3] < edge[3], 'colorToAlpha: the threshold should make more of the edge transparent');
509+
}
510+
497511
// slice-by-16 crc32 matches zlib for every length around the 16 byte steps and for unaligned views (#12)
498512
{
499513
const {crc32} = await import('../dist/codecs/crc.js');

0 commit comments

Comments
 (0)