Skip to content

Commit 0767ec8

Browse files
committed
add transform
1 parent e9f00e5 commit 0767ec8

5 files changed

Lines changed: 332 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- **`image.quantize({ colors, palette, dither, ditherStrength })`**: reduces the image to a palette, in place: `image.quantize({ colors: 16 })` for the 16 colors that best represent it, or a fixed one: `image.quantize({ palette: [0x0f380fff, 0x306230ff, 0x8bac0fff, 0x9bbc0fff] })` for a Game Boy look. `dither: 'floyd-steinberg'` (or `'atkinson'`, or `'ordered'` for a regular pattern) makes gradients look smooth with few colors. Transparent pixels keep their color and don't count towards the palette.
88
- **`Image.renderSVG(svg, { images })`**: images for the `<image>` elements by their `href`, without data URIs: `Image.renderSVG('<svg ...><image href="logo.png" .../></svg>', { images: { 'logo.png': bytes } })`. The values are encoded images or `Image`s; they are decoded only when the SVG uses them. Nothing is read from files or URLs.
99
- **`image.colorMatrix(matrix)`**: mixes the colors of every pixel with a color matrix, like SVG's `feColorMatrix`: greyscale, sepia, tints, swapped channels, color blindness simulation or any mix of them in one pass. Twenty values (four rows of `[red, green, blue, alpha, offset]`, 0 to 1) or nine for just the colors: `image.colorMatrix([0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0])` swaps red and blue.
10+
- **`image.transform(matrix, { origin, expand, mode })`**: moves every point of the image with an affine transform in one resampling: rotation, scaling, skew, flips and shifts in any mix and around any point. The six values are SVG's `matrix(a b c d e f)`: `image.transform([1, 0, -0.3, 1, 0, 0])` slants an image like italics. The image grows to hold the result unless `expand: false`, and `mode` is `'nearest-neighbor'`, `'bilinear'` (default) or `'bicubic'`.
1011

1112
## 3.19.0
1213

‎src/image.ts‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import { isOptions, finite, color as checkColor, quality as checkQuality, effort
3737
import { MAX_PIXELS, decoding } from './util/limits.ts';
3838
import { GainMap } from './gainmap.ts';
3939
import * as gain from './util/gain.ts';
40+
import { multiply, invert } from './util/matrix.ts';
41+
import { sampleAffine } from './util/affine.ts';
4042
import * as gainMapCodec from './codecs/gainmap.ts';
4143

4244
/** A color as a 32-bit RGBA integer (`0xRRGGBBAA`) */
@@ -203,6 +205,38 @@ export interface QuantizeOptions {
203205
ditherStrength?: number;
204206
}
205207

208+
/** How {@link Image.transform} samples the pixels between the source's */
209+
export type TransformMode = 'nearest-neighbor' | 'bilinear' | 'bicubic';
210+
211+
// the sampling modes of Image.transform()
212+
const TRANSFORM_MODES: readonly string[] = ['nearest-neighbor', 'bilinear', 'bicubic'];
213+
214+
/** Options of {@link Image.transform} */
215+
export interface TransformOptions {
216+
/**
217+
* The point the transform is applied around, in pixels: a rotation turns around it, a scale grows from it.
218+
* Default: the middle of the image. It only decides where the result lies on a canvas that stays as large as the
219+
* image (`expand: false`)
220+
*/
221+
origin?: {
222+
/** The x position of the point in pixels. Default: half the width */
223+
x?: number;
224+
/** The y position of the point in pixels. Default: half the height */
225+
y?: number;
226+
};
227+
/**
228+
* Whether to make the image as large as the transformed one, which then fills it (a rotated image grows to fit its
229+
* corners, a skewed one gets wider). Without it, the image keeps its size and the parts outside are lost. Frames
230+
* only grow up and left as far as their offset allows. Default: `true`
231+
*/
232+
expand?: boolean;
233+
/**
234+
* How the pixels between the source's are sampled: `'nearest-neighbor'` (hard pixels, no smoothing, for pixel art),
235+
* `'bilinear'` (smooth), `'bicubic'` (sharper, slower, can overshoot a little at hard edges). Default: `'bilinear'`
236+
*/
237+
mode?: TransformMode;
238+
}
239+
206240
/** Options of {@link Image.dropShadow} */
207241
export interface ShadowOptions {
208242
/** How far to move the shadow to the right (negative values: to the left), in pixels. Default: `0` */
@@ -2142,6 +2176,81 @@ export class Image {
21422176
return this;
21432177
}
21442178

2179+
/**
2180+
* Moves every point of the image with an affine transform, in one resampling: rotation, scaling, skew, flips and
2181+
* shifts in any mix and around any point. The six numbers are SVG's `matrix(a b c d e f)`: a point (x, y) of the
2182+
* image ends up at (a x + c y + e, b x + d y + f), with y pointing down.
2183+
* ```ts
2184+
* image.transform([1, 0, Math.tan(-0.3), 1, 0, 0]); // slanted like italics, around the middle
2185+
* image.transform([0.5, 0, 0, 0.5, 0, 0], { origin: { x: 0, y: 0 } }); // half the size
2186+
* image.transform([-1, 0, 0, 1, 0, 0]); // flipped horizontally
2187+
* const a = Math.PI / 6;
2188+
* image.transform([Math.cos(a), Math.sin(a), -Math.sin(a), Math.cos(a), 0, 0]); // turned 30° clockwise
2189+
* ```
2190+
* The image grows to hold the result (`expand: false` keeps its size), so a shift only shows with `expand: false`.
2191+
* The edges fade, like the pixels outside were transparent. A transform that flattens the image (a factor of 0) is
2192+
* refused. With a gain map (an HDR image), the map moves with the image
2193+
* @param matrix The six values [a, b, c, d, e, f]
2194+
* @param options See {@link TransformOptions}
2195+
*/
2196+
transform(matrix: ArrayLike<number>, options: TransformOptions = {}): this {
2197+
if (matrix === null || typeof matrix !== 'object' || matrix.length !== 6) throw new RangeError('A transform has 6 values: [a, b, c, d, e, f]');
2198+
// (numbers only: a string is a mistake in data like this)
2199+
const values = Array.from({ length: 6 }, (_, i) => finite(typeof matrix[i] === 'number' ? matrix[i] : NaN, 'transform value'));
2200+
if (typeof options !== 'object' || options === null) throw new TypeError('transform takes an options object');
2201+
const { origin = {}, expand = true, mode = 'bilinear' } = options;
2202+
if (!TRANSFORM_MODES.includes(mode)) throw new TypeError(`transform mode must be one of ${TRANSFORM_MODES.map(name => `'${name}'`).join(', ')}, not ${String(mode)}`);
2203+
const { x: pivotX = this.width / 2, y: pivotY = this.height / 2 } = origin;
2204+
finite(pivotX, 'origin x');
2205+
finite(pivotY, 'origin y');
2206+
2207+
// around the origin: move it to (0, 0), transform, move it back
2208+
const around = multiply(multiply([1, 0, 0, 1, pivotX, pivotY], values), [1, 0, 0, 1, -pivotX, -pivotY]);
2209+
const inverse = invert(around);
2210+
if (!inverse) throw new RangeError('The transform has to be invertible: it can\'t flatten the image');
2211+
2212+
const { width, height } = this;
2213+
// (the canvas's top left corner in the transformed space, which isn't on a pixel border for a scale around the
2214+
// middle of an image of odd size: then the grid starts there, so that doubling an image is exactly doubling)
2215+
let left = 0, top = 0, outWidth = width, outHeight = height;
2216+
if (expand) {
2217+
const xs: number[] = [], ys: number[] = [];
2218+
for (const [x, y] of [[0, 0], [width, 0], [0, height], [width, height]]) {
2219+
xs.push(around[0] * x + around[2] * y + around[4]);
2220+
ys.push(around[1] * x + around[3] * y + around[5]);
2221+
}
2222+
const minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys);
2223+
// the size rounds, the transformed image is centered on the canvas (a turned image is a bit off a pixel size)
2224+
outWidth = Math.max(1, Math.round(maxX - minX));
2225+
outHeight = Math.max(1, Math.round(maxY - minY));
2226+
left = minX - (outWidth - (maxX - minX)) / 2;
2227+
top = minY - (outHeight - (maxY - minY)) / 2;
2228+
// frames only grow as far up and left as their offset allows
2229+
const room = this.__room__();
2230+
if (left < -room.left) {
2231+
left = -room.left;
2232+
outWidth = Math.max(1, Math.round(maxX - left));
2233+
}
2234+
if (top < -room.top) {
2235+
top = -room.top;
2236+
outHeight = Math.max(1, Math.round(maxY - top));
2237+
}
2238+
if (outWidth * outHeight > MAX_PIXELS) throw new RangeError(`The transformed image would have ${outWidth}x${outHeight} pixels, more than the ${MAX_PIXELS} allowed`);
2239+
}
2240+
2241+
const out = new Image(outWidth, outHeight);
2242+
sampleAffine(this.bitmap, width, height, out.bitmap, outWidth, outHeight, inverse, left, top, mode);
2243+
const scale = Math.sqrt(Math.abs(around[0] * around[3] - around[1] * around[2]));
2244+
this.__gain_map__?.__transform__(width, height, outWidth, outHeight, (X, Y) => {
2245+
const x = X + left, y = Y + top;
2246+
return [inverse[0] * x + inverse[2] * y + inverse[4], inverse[1] * x + inverse[3] * y + inverse[5]];
2247+
}, scale);
2248+
this.__apply__(out);
2249+
// (a frame's offsets are whole pixels)
2250+
if (left || top) this.__grown__(-Math.round(left), -Math.round(top));
2251+
return this;
2252+
}
2253+
21452254
/**
21462255
* Adds a shadow behind the image, shaped by its alpha: a drop shadow with an offset, or a glow without one
21472256
* (`image.dropShadow({ blur: 8, color: 0xff2fb9ff })`, with `spread` to make it stronger). The image grows to fit

‎src/index.ts‎

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

1010
export { Image, ResizeModes, SVGScaleModes } from './image.ts';
1111
export { Colors } from './colors.ts';
12-
export type { Color, ColorFunction, ResizeMode, SVGScaleMode, PNGMetadata, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, AdjustOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, SVGOptions, TextOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts';
12+
export type { Color, ColorFunction, ResizeMode, SVGScaleMode, PNGMetadata, TextStyle, DecodeOptions, CompositeOptions, BlendMode, CropCircleOptions, DominantColorOptions, RotateOptions, ColorToAlphaOptions, ResizeOptions, ResizeToOptions, AdjustOptions, SharpenOptions, PNGEncodeOptions, JPEGEncodeOptions, WebPEncodeOptions, AVIFEncodeOptions, JXLEncodeOptions, EncodeOptions, SVGOptions, TextOptions, RenderTextOptions, ShadowOptions, OutlineOptions, QuantizeOptions, TransformOptions, TransformMode, GradientOptions, LinearGradientOptions, RadialGradientOptions, RoundCornersOptions, FisheyeOptions, TrimOptions } from './image.ts';
1313
export { Frame, FrameDisposalModes } from './frame.ts';
1414
export type { FrameDisposalModeName, FrameDisposalModeId, FrameOptions } from './frame.ts';
1515
export { GainMap } from './gainmap.ts';

‎src/util/affine.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// resampling an image through an affine transform, on straight (non premultiplied) 8-bit RGBA
2+
3+
import type { Matrix } from './matrix.ts';
4+
5+
/** How the pixels between the source's are sampled */
6+
export type SampleMode = 'nearest-neighbor' | 'bilinear' | 'bicubic';
7+
8+
// Catmull-Rom weights of the four taps around a position (t is the fraction between the 2nd and the 3rd)
9+
function cubic(t: number, out: Float64Array): void {
10+
const t2 = t * t, t3 = t2 * t;
11+
out[0] = -0.5 * t3 + t2 - 0.5 * t;
12+
out[1] = 1.5 * t3 - 2.5 * t2 + 1;
13+
out[2] = -1.5 * t3 + 2 * t2 + 0.5 * t;
14+
out[3] = 0.5 * t3 - 0.5 * t2;
15+
}
16+
17+
/**
18+
* Fills `out` with the source as seen through `inverse` (the transform from the output's space to the source's: x' = a x
19+
* + c y + e, y' = b x + d y + f). The output's top left pixel is at (`left`, `top`) in that space. Positions are
20+
* continuous (pixel x spans x to x + 1) and samples are taken at pixel centers, so the identity copies the source.
21+
* Pixels outside of the source are transparent: the edges fade, like the taps outside were transparent. Colors are
22+
* weighted by their alpha, so transparent pixels don't bleed their color
23+
*/
24+
export function sampleAffine(src: Uint8ClampedArray, width: number, height: number, out: Uint8ClampedArray, outWidth: number, outHeight: number, inverse: Matrix, left: number, top: number, mode: SampleMode): void {
25+
const [a, b, c, d, e, f] = inverse;
26+
const wx = new Float64Array(4), wy = new Float64Array(4);
27+
28+
for (let oy = 0; oy < outHeight; oy++) {
29+
const y = oy + top + 0.5;
30+
// the source position of the row's first pixel center, then a step per pixel
31+
const sx0 = a * (left + 0.5) + c * y + e, sy0 = b * (left + 0.5) + d * y + f;
32+
33+
for (let ox = 0; ox < outWidth; ox++) {
34+
const sx = sx0 + a * ox, sy = sy0 + b * ox;
35+
const o = 4 * (oy * outWidth + ox);
36+
37+
if (mode === 'nearest-neighbor') {
38+
const ix = Math.floor(sx), iy = Math.floor(sy);
39+
if (ix < 0 || iy < 0 || ix >= width || iy >= height) continue;
40+
const i = 4 * (iy * width + ix);
41+
out[o] = src[i];
42+
out[o + 1] = src[i + 1];
43+
out[o + 2] = src[i + 2];
44+
out[o + 3] = src[i + 3];
45+
continue;
46+
}
47+
48+
// the taps are at pixel centers
49+
const u = sx - 0.5, v = sy - 0.5;
50+
const x0 = Math.floor(u), y0 = Math.floor(v), fx = u - x0, fy = v - y0;
51+
let r = 0, g = 0, bl = 0, alpha = 0;
52+
53+
if (mode === 'bilinear') {
54+
if (x0 < -1 || y0 < -1 || x0 >= width || y0 >= height) continue;
55+
for (let j = 0; j < 2; j++) {
56+
const yy = y0 + j;
57+
if (yy < 0 || yy >= height) continue;
58+
const weightY = j ? fy : 1 - fy;
59+
for (let i = 0; i < 2; i++) {
60+
const xx = x0 + i;
61+
if (xx < 0 || xx >= width) continue;
62+
const p = 4 * (yy * width + xx), weight = (i ? fx : 1 - fx) * weightY * src[p + 3];
63+
r += weight * src[p];
64+
g += weight * src[p + 1];
65+
bl += weight * src[p + 2];
66+
alpha += weight;
67+
}
68+
}
69+
} else {
70+
if (x0 < -2 || y0 < -2 || x0 > width || y0 > height) continue;
71+
cubic(fx, wx);
72+
cubic(fy, wy);
73+
for (let j = 0; j < 4; j++) {
74+
const yy = y0 - 1 + j;
75+
if (yy < 0 || yy >= height) continue;
76+
for (let i = 0; i < 4; i++) {
77+
const xx = x0 - 1 + i;
78+
if (xx < 0 || xx >= width) continue;
79+
const p = 4 * (yy * width + xx), weight = wx[i] * wy[j] * src[p + 3];
80+
r += weight * src[p];
81+
g += weight * src[p + 1];
82+
bl += weight * src[p + 2];
83+
alpha += weight;
84+
}
85+
}
86+
}
87+
88+
if (alpha <= 0) continue;
89+
// (the clamped array rounds and clamps on assignment: the lobes of the cubic can overshoot)
90+
out[o] = r / alpha;
91+
out[o + 1] = g / alpha;
92+
out[o + 2] = bl / alpha;
93+
out[o + 3] = alpha;
94+
}
95+
}
96+
}

0 commit comments

Comments
 (0)