Skip to content

Commit 22c7825

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 c50cc03 commit 22c7825

5 files changed

Lines changed: 67 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## 3.1.0 (unreleased)
4+
5+
### New
6+
7+
- **`colorToAlpha(color, options)`** (#39): makes a color transparent like GIMP's "Color to Alpha", antialiased edges
8+
become translucent instead of keeping a fringe of the removed color. Without a color, the top left pixel's is used.
9+
310
## 3.0.0
411

512
ImageScript is now written in TypeScript and has no WebAssembly or native binaries anymore. Every codec and renderer

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
3939
, [sharpen](https://imagescript.matmen.dev/classes/Image.html#sharpen)
4040
, [brightness](https://imagescript.matmen.dev/classes/Image.html#brightness)
4141
, [hueShift](https://imagescript.matmen.dev/classes/Image.html#hueshift), ...)
42-
- Compositing with opacity and blend modes ([composite](https://imagescript.matmen.dev/classes/Image.html#composite))
42+
- Compositing with opacity and blend modes ([composite](https://imagescript.matmen.dev/classes/Image.html#composite)),
43+
removing backgrounds ([colorToAlpha](https://imagescript.matmen.dev/classes/Image.html#colortoalpha))
4344
- Color information functions ([averageColor](https://imagescript.matmen.dev/classes/Image.html#averagecolor)
4445
, [dominantColor](https://imagescript.matmen.dev/classes/Image.html#dominantcolor), ...)
4546
- 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
@@ -73,6 +73,14 @@ export interface RotateOptions {
7373
resize?: boolean;
7474
}
7575

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

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

‎src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { GIF } from './gif.ts';
44
import { ImageType } from './image-type.ts';
55

66
export { Image } from './image.ts';
7-
export type { Color, ColorFunction, ResizeMode, SVGScaleMode, PNGMetadata, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions } from './image.ts';
7+
export type { Color, ColorFunction, ResizeMode, SVGScaleMode, PNGMetadata, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions } from './image.ts';
88
export { Frame } from './frame.ts';
99
export type { FrameDisposalModeName, FrameDisposalModeId } from './frame.ts';
1010
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)