diff --git a/CHANGELOG.md b/CHANGELOG.md index 208c081b..f13070de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 3.2.1 + +### Performance + +- Resizing is 2-4 times faster for opaque images with bilinear and area resizing (halving a 1920×1080 photo: 15 instead + of 49 ms), and 20-40% faster otherwise: the arithmetic is integer, the vertical pass runs first, and for opaque + images two channels share a multiplication. Results differ from 3.2.0 by at most 1 per channel (more only in the + invisible color of pixels that are almost fully transparent). +- `clone()` is about 50% faster. + +### Fixes + +- Enlarging with `RESIZE_AREA` left transparent rows and columns where a pixel's center fell right on the edge of the + filter. + ## 3.2.0 ### New diff --git a/src/image.ts b/src/image.ts index 3dc2ff89..6b115c35 100644 --- a/src/image.ts +++ b/src/image.ts @@ -529,8 +529,11 @@ export class Image { * Clones the current image */ clone(): Image { - const image = new Image(this.width, this.height); - image.bitmap.set(this.bitmap); + // copying in the constructor of the array saves zeroing the memory first + const image = new Image(1, 1); + image.__width__ = this.width; + image.__height__ = this.height; + image.__set_bitmap__(new Uint8ClampedArray(this.bitmap)); return image; } diff --git a/src/util/resample.ts b/src/util/resample.ts index 4372bee2..c07780b0 100644 --- a/src/util/resample.ts +++ b/src/util/resample.ts @@ -1,5 +1,7 @@ // separable convolution resampling (the approach of Pillow's resample.c): the filter is stretched by the -// downscaling factor, so shrinking averages every source pixel instead of skipping most of them +// downscaling factor, so shrinking averages every source pixel instead of skipping most of them. +// The arithmetic is integer (weights are fixed point numbers), and the vertical pass runs first: its weights are the +// same for a whole row, so it's the cheaper pass to run on the larger image export interface Filter { /** radius of the filter at scale 1 */ @@ -23,126 +25,297 @@ export const CUBIC: Filter = { } }; +/** fractional bits of the weights */ +const BITS = 14; +const ONE = 1 << BITS; + interface Coefficients { /** first source index per output index */ start: Int32Array; - /** source pixel count per output index */ - count: Int32Array; - /** normalized weights, `size` per output index */ - weights: Float64Array; - size: number; + /** weights with `bits` fractional bits, `taps` per output index (summing to 1) */ + weights: Int32Array; + taps: number; } -function coefficients(inSize: number, outSize: number, filter: Filter): Coefficients { +function coefficients(inSize: number, outSize: number, filter: Filter, bits: number): Coefficients { const scale = inSize / outSize; const filterScale = Math.max(1, scale); const support = filter.support * filterScale; - const size = Math.ceil(support) * 2 + 1; - - const start = new Int32Array(outSize); - const count = new Int32Array(outSize); - const weights = new Float64Array(outSize * size); + const one = 1 << bits; + const mins = new Int32Array(outSize), maxs = new Int32Array(outSize); + let taps = 0; for (let i = 0; i < outSize; i++) { const center = (i + 0.5) * scale; - const min = Math.max(0, Math.floor(center - support + 0.5)); - const max = Math.min(inSize, Math.floor(center + support + 0.5)); + mins[i] = Math.max(0, Math.floor(center - support + 0.5)); + maxs[i] = Math.min(inSize, Math.floor(center + support + 0.5)); + taps = Math.max(taps, maxs[i] - mins[i]); + } + // every output index gets `taps` weights, so the passes can unroll by the count: a shorter range starts earlier + // (zero weights for the pixels before it) where it would read past the end + const start = new Int32Array(outSize); + const weights = new Int32Array(outSize * taps); + const w = new Float64Array(taps); + for (let i = 0; i < outSize; i++) { + const center = (i + 0.5) * scale, min = mins[i], n = maxs[i] - min; let total = 0; - for (let x = min; x < max; x++) { - const w = filter.weight((x - center + 0.5) / filterScale); - weights[i * size + x - min] = w; - total += w; - } + for (let k = 0; k < n; k++) total += w[k] = filter.weight((min + k - center + 0.5) / filterScale); - if (total !== 0) for (let k = 0; k < max - min; k++) weights[i * size + k] /= total; - start[i] = min; - count[i] = max - min; + const shift = Math.max(0, min + taps - inSize), o = i * taps + shift; + start[i] = min - shift; + // the rounded weights still sum to one: the largest takes the difference. Without any weight (the box filter + // when enlarging, with the pixel right on its edge) the pixel in range is used + let sum = 0, largest = 0; + for (let k = 0; k < n; k++) { + sum += weights[o + k] = Math.round(total ? w[k] / total * one : 0); + if (Math.abs(w[k]) > Math.abs(w[largest])) largest = k; + } + if (n) weights[o + largest] += one - sum; } - return { start, count, weights, size }; + return { start, weights, taps }; } +const littleEndian = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1; + /** * Resamples RGBA pixels with the given filter, in premultiplied alpha so transparent pixels don't bleed their color */ export function resample(src: Uint8ClampedArray, width: number, height: number, outWidth: number, outHeight: number, filter: Filter, out: Uint8ClampedArray): void { - // premultiplied float rows, scaled horizontally (premultiplying on the fly) - let plane = new Float32Array(outWidth * height * 4); - - if (outWidth !== width) { - const { start, count, weights, size } = coefficients(width, outWidth, filter); - for (let y = 0, o = 0; y < height; y++) { - const row = y * width * 4; - for (let x = 0; x < outWidth; x++, o += 4) { - let r = 0, g = 0, b = 0, a = 0; - for (let k = 0, s = row + start[x] * 4, w = x * size, n = count[x]; k < n; k++, s += 4) { + // opaque pixels don't need premultiplying, and with non-negative weights two channels share a multiplication + if (littleEndian && filter.support <= 1 && resampleOpaque(src, width, height, outWidth, outHeight, filter, out)) return; + resamplePremultiplied(src, width, height, outWidth, outHeight, filter, out); +} + +/** + * Resamples opaque pixels (as 32-bit words, little endian), returns false (without writing anything) if they aren't + * all opaque. Between the passes, each pixel is two words of two 16-bit lanes, the channels with 8 fractional bits: + * red and blue in the first, green in the second + */ +function resampleOpaque(src: Uint8ClampedArray, width: number, height: number, outWidth: number, outHeight: number, filter: Filter, out: Uint8ClampedArray): boolean { + const pixels = new Int32Array(src.buffer, src.byteOffset, width * height); + // transparent images usually show it in the first or last row, before a pass is spent on them + let all = -1; + for (let x = 0, last = (height - 1) * width; x < width; x++) all &= pixels[x] & pixels[last + x]; + if (all >>> 24 !== 255) return false; + + const lanes = new Int32Array(width * outHeight * 2); + if (outHeight === height) { + for (let i = 0, o = 0; i < pixels.length; i++, o += 2) { + const p = pixels[i]; + all &= p; + lanes[o] = (p & 0xff00ff) << 8; + lanes[o + 1] = (p & 0xff00) | 0; + } + } else { + // with up to 4 rows (bilinear to half the size, area to a third), 8-bit weights stay within a step of the + // exact result: then red and blue fit into one multiplication (each lane at most 255 * 256) + let coefficientsV = coefficients(height, outHeight, filter, 8); + const swar = coefficientsV.taps <= 4; + if (!swar) coefficientsV = coefficients(height, outHeight, filter, BITS); + const { start, weights, taps } = coefficientsV; + for (let y = 0, o = 0; y < outHeight; y++) { + const w = y * taps, r0 = start[y] * width; + if (swar && taps === 4) { + const w0 = weights[w], w1 = weights[w + 1], w2 = weights[w + 2], w3 = weights[w + 3]; + const r1 = r0 + width, r2 = r1 + width, r3 = r2 + width; + for (let x = 0; x < width; x++, o += 2) { + const p0 = pixels[r0 + x], p1 = pixels[r1 + x], p2 = pixels[r2 + x], p3 = pixels[r3 + x]; + all &= p0 & p1 & p2 & p3; + lanes[o] = Math.imul(p0 & 0xff00ff, w0) + Math.imul(p1 & 0xff00ff, w1) + Math.imul(p2 & 0xff00ff, w2) + Math.imul(p3 & 0xff00ff, w3); + lanes[o + 1] = (p0 >> 8 & 255) * w0 + (p1 >> 8 & 255) * w1 + (p2 >> 8 & 255) * w2 + (p3 >> 8 & 255) * w3; + } + } else if (swar && taps === 3) { + const w0 = weights[w], w1 = weights[w + 1], w2 = weights[w + 2], r1 = r0 + width, r2 = r1 + width; + for (let x = 0; x < width; x++, o += 2) { + const p0 = pixels[r0 + x], p1 = pixels[r1 + x], p2 = pixels[r2 + x]; + all &= p0 & p1 & p2; + lanes[o] = Math.imul(p0 & 0xff00ff, w0) + Math.imul(p1 & 0xff00ff, w1) + Math.imul(p2 & 0xff00ff, w2); + lanes[o + 1] = (p0 >> 8 & 255) * w0 + (p1 >> 8 & 255) * w1 + (p2 >> 8 & 255) * w2; + } + } else if (swar && taps === 2) { + const w0 = weights[w], w1 = weights[w + 1], r1 = r0 + width; + for (let x = 0; x < width; x++, o += 2) { + const p0 = pixels[r0 + x], p1 = pixels[r1 + x]; + all &= p0 & p1; + lanes[o] = Math.imul(p0 & 0xff00ff, w0) + Math.imul(p1 & 0xff00ff, w1); + lanes[o + 1] = (p0 >> 8 & 255) * w0 + (p1 >> 8 & 255) * w1; + } + } else if (swar) { + for (let x = 0; x < width; x++, o += 2) { + let rb = 0, g = 0; + for (let k = 0, s = r0 + x; k < taps; k++, s += width) { + const p = pixels[s], wk = weights[w + k]; + all &= p; + rb += Math.imul(p & 0xff00ff, wk); + g += (p >> 8 & 255) * wk; + } + lanes[o] = rb; + lanes[o + 1] = g; + } + } else { + for (let x = 0; x < width; x++, o += 2) { + let r = 0, g = 0, b = 0, k = 0, s = r0 + x; + for (; k + 1 < taps; k += 2, s += width * 2) { + const p = pixels[s], q = pixels[s + width], w0 = weights[w + k], w1 = weights[w + k + 1]; + all &= p & q; + r += (p & 255) * w0 + (q & 255) * w1; + g += (p >> 8 & 255) * w0 + (q >> 8 & 255) * w1; + b += (p >> 16 & 255) * w0 + (q >> 16 & 255) * w1; + } + if (k < taps) { + const p = pixels[s], w0 = weights[w + k]; + all &= p; + r += (p & 255) * w0; + g += (p >> 8 & 255) * w0; + b += (p >> 16 & 255) * w0; + } + lanes[o] = (r + 32 >> 6) | (b + 32 >> 6) << 16; + lanes[o + 1] = g + 32 >> 6; + } + } + } + } + if (all >>> 24 !== 255) return false; + + const result = new Int32Array(out.buffer, out.byteOffset, outWidth * outHeight); + if (outWidth === width) { + for (let i = 0, o = 0; i < result.length; i++, o += 2) { + const rb = lanes[o], g = lanes[o + 1]; + result[i] = ((rb & 0xffff) + 128 >> 8) | (g + 128 >> 8) << 8 | ((rb >>> 16) + 128 >> 8) << 16 | 0xff000000; + } + return true; + } + + // 8 fractional bits of the lanes and BITS of the weights + const R = 1 << 7 + BITS, S = 8 + BITS; + const { start, weights, taps } = coefficients(width, outWidth, filter, BITS); + for (let y = 0, o = 0; y < outHeight; y++) { + const row = y * width * 2; + if (taps === 4) { + for (let x = 0; x < outWidth; x++, o++) { + const s = row + start[x] * 2, w = x * 4; + const a0 = lanes[s], a1 = lanes[s + 2], a2 = lanes[s + 4], a3 = lanes[s + 6]; + const w0 = weights[w], w1 = weights[w + 1], w2 = weights[w + 2], w3 = weights[w + 3]; + const r = (a0 & 0xffff) * w0 + (a1 & 0xffff) * w1 + (a2 & 0xffff) * w2 + (a3 & 0xffff) * w3; + const b = (a0 >>> 16) * w0 + (a1 >>> 16) * w1 + (a2 >>> 16) * w2 + (a3 >>> 16) * w3; + const g = lanes[s + 1] * w0 + lanes[s + 3] * w1 + lanes[s + 5] * w2 + lanes[s + 7] * w3; + result[o] = (r + R >> S) | (g + R >> S) << 8 | (b + R >> S) << 16 | 0xff000000; + } + } else if (taps === 2) { + for (let x = 0; x < outWidth; x++, o++) { + const s = row + start[x] * 2, w = x * 2; + const a0 = lanes[s], a1 = lanes[s + 2], w0 = weights[w], w1 = weights[w + 1]; + const r = (a0 & 0xffff) * w0 + (a1 & 0xffff) * w1; + const b = (a0 >>> 16) * w0 + (a1 >>> 16) * w1; + const g = lanes[s + 1] * w0 + lanes[s + 3] * w1; + result[o] = (r + R >> S) | (g + R >> S) << 8 | (b + R >> S) << 16 | 0xff000000; + } + } else { + for (let x = 0; x < outWidth; x++, o++) { + let r = 0, g = 0, b = 0, k = 0, s = row + start[x] * 2, w = x * taps; + for (; k + 1 < taps; k += 2, s += 4) { + const a0 = lanes[s], a1 = lanes[s + 2], w0 = weights[w + k], w1 = weights[w + k + 1]; + r += (a0 & 0xffff) * w0 + (a1 & 0xffff) * w1; + b += (a0 >>> 16) * w0 + (a1 >>> 16) * w1; + g += lanes[s + 1] * w0 + lanes[s + 3] * w1; + } + if (k < taps) { + const a0 = lanes[s], w0 = weights[w + k]; + r += (a0 & 0xffff) * w0; + b += (a0 >>> 16) * w0; + g += lanes[s + 1] * w0; + } + result[o] = (r + R >> S) | (g + R >> S) << 8 | (b + R >> S) << 16 | 0xff000000; + } + } + } + return true; +} + +/** + * Resamples any pixels, premultiplied: between the passes, the color channels are channel * alpha and the alpha + * channel is alpha * 255 (0-65025 each, beyond with overshooting filters) + */ +function resamplePremultiplied(src: Uint8ClampedArray, width: number, height: number, outWidth: number, outHeight: number, filter: Filter, out: Uint8ClampedArray): void { + const plane = new Int32Array(width * outHeight * 4); + if (outHeight === height) { + for (let s = 0; s < src.length; s += 4) { + const a = src[s + 3]; + plane[s] = src[s] * a; + plane[s + 1] = src[s + 1] * a; + plane[s + 2] = src[s + 2] * a; + plane[s + 3] = a * 255; + } + } else { + const { start, weights, taps } = coefficients(height, outHeight, filter, BITS); + const stride = width * 4, H = ONE >> 1; + for (let y = 0, o = 0; y < outHeight; y++) { + const w = y * taps, r0 = start[y] * stride; + for (let x = 0; x < stride; x += 4, o += 4) { + let r = 0, g = 0, b = 0, a = 0, k = 0, s = r0 + x; + for (; k + 1 < taps; k += 2, s += stride * 2) { + const t = s + stride, wa = weights[w + k] * src[s + 3], wb = weights[w + k + 1] * src[t + 3]; + r += src[s] * wa + src[t] * wb; + g += src[s + 1] * wa + src[t + 1] * wb; + b += src[s + 2] * wa + src[t + 2] * wb; + a += wa + wb; + } + if (k < taps) { const wa = weights[w + k] * src[s + 3]; r += src[s] * wa; g += src[s + 1] * wa; b += src[s + 2] * wa; a += wa; } - - plane[o] = r / 255; - plane[o + 1] = g / 255; - plane[o + 2] = b / 255; - plane[o + 3] = a; + plane[o] = r + H >> BITS; + plane[o + 1] = g + H >> BITS; + plane[o + 2] = b + H >> BITS; + plane[o + 3] = a * 255 + H >> BITS; } } - } else { - for (let s = 0; s < plane.length; s += 4) { - const a = src[s + 3]; - plane[s] = src[s] * a / 255; - plane[s + 1] = src[s + 1] * a / 255; - plane[s + 2] = src[s + 2] * a / 255; - plane[s + 3] = a; - } } - if (outHeight !== height) { - const { start, count, weights, size } = coefficients(height, outHeight, filter); - const next = new Float32Array(outWidth * outHeight * 4); - const stride = outWidth * 4; - - // every sum is rounded to float32 after each row it adds, as accumulating in a Float32Array would. Up to 4 rows - // (bilinear and bicubic, from half the size up) are added with the weights in variables, more in a loop - for (let y = 0; y < outHeight; y++) { - const o = y * stride, n = count[y], w = y * size; - const s0 = start[y] * stride, s1 = s0 + stride, s2 = s1 + stride, s3 = s2 + stride; - const w0 = weights[w], w1 = weights[w + 1], w2 = weights[w + 2], w3 = weights[w + 3]; - if (n === 1) for (let x = 0; x < stride; x++) next[o + x] = 0 + plane[s0 + x] * w0; - else if (n === 2) for (let x = 0; x < stride; x++) next[o + x] = Math.fround(0 + plane[s0 + x] * w0) + plane[s1 + x] * w1; - else if (n === 3) { - for (let x = 0; x < stride; x++) - next[o + x] = Math.fround(Math.fround(0 + plane[s0 + x] * w0) + plane[s1 + x] * w1) + plane[s2 + x] * w2; - } else if (n === 4) { - for (let x = 0; x < stride; x++) - next[o + x] = Math.fround(Math.fround(Math.fround(0 + plane[s0 + x] * w0) + plane[s1 + x] * w1) + plane[s2 + x] * w2) + plane[s3 + x] * w3; + const { start, weights, taps } = outWidth === width ? { start: null, weights: null, taps: 0 } : coefficients(width, outWidth, filter, BITS); + for (let y = 0, o = 0; y < outHeight; y++) { + const row = y * width * 4; + for (let x = 0; x < outWidth; x++, o += 4) { + let r = 0, g = 0, b = 0, a = 0; + if (!start || !weights) { + const s = row + x * 4; + r = plane[s] * ONE; + g = plane[s + 1] * ONE; + b = plane[s + 2] * ONE; + a = plane[s + 3] * ONE; } else { - for (let x = 0; x < stride; x++) { - let sum = 0; - for (let k = 0, s = s0 + x; k < n; k++, s += stride) sum = Math.fround(sum + plane[s] * weights[w + k]); - next[o + x] = sum; + let k = 0, s = row + start[x] * 4, w = x * taps; + for (; k + 1 < taps; k += 2, s += 8) { + const w0 = weights[w + k], w1 = weights[w + k + 1]; + r += plane[s] * w0 + plane[s + 4] * w1; + g += plane[s + 1] * w0 + plane[s + 5] * w1; + b += plane[s + 2] * w0 + plane[s + 6] * w1; + a += plane[s + 3] * w0 + plane[s + 7] * w1; + } + if (k < taps) { + const w0 = weights[w + k]; + r += plane[s] * w0; + g += plane[s + 1] * w0; + b += plane[s + 2] * w0; + a += plane[s + 3] * w0; } } - } - - plane = next; - } - // unpremultiply, the clamped output rounds and clamps (cubic filters overshoot) - for (let i = 0; i < plane.length; i += 4) { - const a = plane[i + 3]; - if (a < 0.5) { - out[i] = out[i + 1] = out[i + 2] = out[i + 3] = 0; - continue; + // a is alpha * 255 with BITS fractional bits: unpremultiply, the clamped output rounds and clamps + if (a < 127.5 * ONE) { + out[o] = out[o + 1] = out[o + 2] = out[o + 3] = 0; + continue; + } + const f = 255 / Math.min(a, 65025 * ONE); + out[o] = r * f; + out[o + 1] = g * f; + out[o + 2] = b * f; + out[o + 3] = a / (255 * ONE); } - - const f = 255 / Math.min(255, a); - out[i] = plane[i] * f; - out[i + 1] = plane[i + 1] * f; - out[i + 2] = plane[i + 2] * f; - out[i + 3] = a; } } diff --git a/tests/targets/effects.png b/tests/targets/effects.png index 134c175a..9ceb6085 100644 Binary files a/tests/targets/effects.png and b/tests/targets/effects.png differ diff --git a/tests/targets/readme.png b/tests/targets/readme.png index c01e1e79..c2073dea 100644 Binary files a/tests/targets/readme.png and b/tests/targets/readme.png differ diff --git a/tests/targets/resize.png b/tests/targets/resize.png index a7ab3b27..c1cee840 100644 Binary files a/tests/targets/resize.png and b/tests/targets/resize.png differ