From 95e1f3e375d999bae44f42c74e08c4056e75afb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sun, 14 Jun 2020 00:10:41 +0200 Subject: [PATCH 1/4] d3.blur fixes https://github.com/d3/d3-array/issues/56 --- README.md | 32 ++++++++++++++++ src/blur.js | 94 +++++++++++++++++++++++++++++++++++++++++++++++ src/index.js | 1 + src/math.js | 5 +++ src/utils.js | 3 ++ test/blur-test.js | 72 ++++++++++++++++++++++++++++++++++++ 6 files changed, 207 insertions(+) create mode 100644 src/blur.js create mode 100644 src/math.js create mode 100644 src/utils.js create mode 100644 test/blur-test.js diff --git a/README.md b/README.md index 1bcab860..e89132ca 100644 --- a/README.md +++ b/README.md @@ -683,6 +683,38 @@ Returns an array of arrays, where the *i*th array contains the *i*th element fro d3.zip([1, 2], [3, 4]); // returns [[1, 3], [2, 4]] ``` +#### Blur + +# blur() · [Source](https://github.com/d3/d3-array/blob/main/src/blur.js), [Examples](https://observablehq.com/@d3/d3-blur) + +Creates a blur transformer, which can blur (or smooth) an *iterable* by three iterations of a moving average transform. + +# *blur*.radius([*radius*]) + +If *radius* is specified, sets the radius of the transformation: on each iteration, the value of a point is transformed into the mean value of itself and the *radius* points of data surrounding it on the left and on the right (taking into account the edges). If *radius* is not specified, returns the current radius (if horizontal and vertical radii have been set separately, returns their average value). If *radius* is not an integer value, the blurring is applied partially. Defaults to 5. + +# *blur*.value([*value*]) + +If *value* is specified, sets the *value* accessor, which will read the values of the *iterator*. If not specified, returns the current accessor. Defaults to the special *null* accessor, which copies the values directly (faster than an identity function). + +Example: +```js +const blurred = blur().value(d => d.temperature)(data); +``` + +# *blur*.width([*width*]) + +If *width* is specified, sets the width of the transformation, otherwise returns the current width. When 0 < width < length, *blur* considers that the *array* describes values in two dimensions—as a rectangle of a certain width (height inferred as length divided by width). In that case each iteration involves an horizontal (x) blurring, followed by a vertical (y) blurring. Defaults to undefined (horizontal dimension). + +# *blur*.radiusX([*radius*]) + +If *radius* is specified, sets the horizontal radius of the transformation, otherwise returns it. (Use 0 for vertical blurring.) + +# *blur*.radiusY([*radius*]) + +If *radius* is specified, sets the vertical radius of the transformation, otherwise returns it. (Use 0 for horizontal blurring.) + + ### Iterables These are equivalent to built-in array methods, but work with any iterable including Map, Set, and Generator. diff --git a/src/blur.js b/src/blur.js new file mode 100644 index 00000000..f37811c2 --- /dev/null +++ b/src/blur.js @@ -0,0 +1,94 @@ +import {floor, max, min} from "./math.js"; +import {arrayify} from "./utils.js"; + +function blurTransfer(V, r, n, vertical) { + if (!r) return; // radius 0 is a noop + + const iterations = 3, r0 = Math.floor(r); + // for a non-integer radius, interpolate between floor(r) and ceil(r) + if (r === r0) { + for (let i = 0; i < iterations; i++) { + blurTransferInt(V, r, n, vertical); + } + } else { + const frac = r - r0, frac_1 = 1 - frac; + const data = V[0].slice(); + for (let i = 0; i < iterations; i++) { + blurTransferInt(V, r0 + 1, n, vertical); + } + const data_ceil = V[0]; + V[0] = data; + if (r0 > 0) { + for (let i = 0; i < iterations; i++) { + blurTransferInt(V, r0, n, vertical); + } + } + for (let i = 0; i < data.length; i++) { + V[0][i] = V[0][i] * frac_1 + data_ceil[i] * frac; + } + } +} + +function blurTransferInt(V, r, n, vertical) { + const [source, target] = V, + m = floor(source.length / n), + w = 2 * r + 1, + w1 = 1 / w, + ki = vertical ? m : 1, + kj = vertical ? 1 : n, + W = w * ki, + R = r * ki; + + for (let j = 0; j < m; ++j) { + const k0 = kj * j, + kn = k0 + ki * (n - 1); + for (let i = 0, sr = w * source[k0]; i < n + r; ++i) { + const k = ki * i + kj * j; + sr += source[min(k, kn)] - source[max(k - W, k0)]; + target[max(k - R, k0)] = sr * w1; + } + } + V.reverse(); // target becomes V[0] and will be used as source in the next iteration +} + +export default function blur() { + let rx = 5, + ry = rx, + value, + width; + const V = []; + + function blur(data) { + // reuse the V arrays if possible + if (value || !V[0] || V[0].length !== data.length) { + V[0] = value ? Float32Array.from(data, value) : Float32Array.from(data); + V[1] = new Float32Array(V[0].length); + } else { + V[0].set(arrayify(data)); + } + + const n = width || V[0].length; + const m = Math.round(V[0].length / n); + + blurTransfer(V, rx, n, false); + blurTransfer(V, ry, m, true); + + V[0].width = n; + V[0].height = m; + return V[0]; + } + + blur.radius = _ => _ === undefined + ? (rx + ry) / 2 + : (rx = ry = +_, blur); + blur.radiusX = _ => _ === undefined + ? rx : (rx = +_, blur); + blur.radiusY = _ => _ === undefined + ? ry : (ry = +_, blur); + blur.width = _ => + _ === undefined ? width : (width = Math.round(+_), blur); + blur.value = _ => + typeof _ === "function" ? (value = _, blur) : value; + + return blur; +} diff --git a/src/index.js b/src/index.js index 5fb58e8b..497c2b71 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,7 @@ export {default as bisect, bisectRight, bisectLeft, bisectCenter} from "./bisect.js"; export {default as ascending} from "./ascending.js"; export {default as bisector} from "./bisector.js"; +export {default as blur} from "./blur.js"; export {default as count} from "./count.js"; export {default as cross} from "./cross.js"; export {default as cumsum} from "./cumsum.js"; diff --git a/src/math.js b/src/math.js new file mode 100644 index 00000000..0004e5c9 --- /dev/null +++ b/src/math.js @@ -0,0 +1,5 @@ +export const floor = Math.floor; + +export const max = Math.max; + +export const min = Math.min; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 00000000..1aaf7136 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,3 @@ +export function arrayify(values) { + return typeof values !== "object" || "length" in values ? values : Array.from(values); +} diff --git a/test/blur-test.js b/test/blur-test.js new file mode 100644 index 00000000..6d1b6195 --- /dev/null +++ b/test/blur-test.js @@ -0,0 +1,72 @@ +import assert from "assert"; +import {blur} from "../src/index.js"; + +it("blur() returns a default blur generator", () => { + const h = blur(); + assert.equal(h.radius(), 5); + assert.equal(h.radiusX(), 5); + assert.equal(h.radiusY(), 5); + assert.equal(h.width(), undefined); + assert.equal(h.value(), undefined); +}); + +it("blur() blurs in 1D", () => { + const h = blur(); + assert.deepEqual( + h.radius(1).width(0)([0, 0, 0, 0, 0, 0, 27, 0, 0, 0, 0, 0, 0, 0]), + Object.assign(Float32Array.from([0, 0, 0, 1, 3, 6, 7, 6, 3, 1, 0, 0, 0, 0]), { width: 14, height: 1 }) + ); +}); + +it("blur() blurs in 2D", () => { + const h = blur(); + assert.deepEqual( + h.width(4).radiusX(1).radiusY(1)([0, 0, 0, 0, 729, 0, 0, 0, 0, 0, 0, 0]), + Object.assign(Float32Array.from([117, 81, 36, 9, 117, 81, 36, 9, 117, 81, 36, 9]), { width: 4, height: 3 }) + ); +}); + +it("blur().radiusY(0) blurs horizontally", () => { + const h = blur(); + assert.deepEqual( + h.width(4).radiusX(1).radiusY(0)([27, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + Object.assign(Float32Array.from([13, 9, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0]), { width: 4, height: 3 }) + ); +}); + +it("blur().radiusX(0) blurs vertically", () => { + const h = blur(); + assert.deepEqual( + h.width(4).radiusX(0).radiusY(1)([0, 0, 0, 27, 3, -9, 0, 0, 0, 0, 0, 0]), + Object.assign(Float32Array.from([1, -3, 0, 13, 1, -3, 0, 9, 1, -3, 0, 5]), { width: 4, height: 3 }) + ); +}); + +it("blur().radius(0.5) does a fraction of blur", () => { + const h = blur().width(5), V = [0,0,0,0,0, 0,0,5184,0,0, 0,0,0,0,0]; + assert.deepEqual( + h.radius(0.5)(V), + Object.assign(Float32Array.from([64, 96, 544, 96, 64, 256, 384, 2176, 384, 256, 64, 96, 544, 96, 64]), { width: 5, height: 3 }) + ); +}); + +it("blur().radius(1.2) does a fraction of blur", () => { + const h = blur(), V = [0,0,0,0,0, 0,0,1,0,0, 0,0,0,0,0]; + const V1 = h.radius(1)(V); + const V2 = h.radius(2)(V); + for (let i = 0; i < V1.length; i++) V1[i] = 0.8 * V1[i] + 0.2 * V2[i]; + assert.deepEqual(Array.from(h.radius(1.2)(V)), Array.from(V1)); +}); + +it("blur().radius() returns the (average) radius", () => { + const h = blur(); + assert.equal(h.width(2).radiusX(1).radiusY(1).radius(), 1); + assert.equal(h.width(2).radius(2).radius(), 2); + assert.equal(h.width(2).radiusX(1).radiusY(5).radius(), 3); +}); + +it("blur() accepts an iterable", () => { + const h = blur().radius(1); + assert.deepEqual(h(new Set([27, 0, -27])), Object.assign(Float32Array.from([8, 0, -8]), { width: 3, height: 1 })); + assert.deepEqual(h.value(d => d.a)(new Set([{a: 27}, {a: 0}, {a: -27}])), Object.assign(Float32Array.from([8, 0, -8]), { width: 3, height: 1 })); +}); From f798c2597d8bbe29d2ffb81ff80d7d06cb03254f Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 2 Jul 2022 10:30:31 -0400 Subject: [PATCH 2/4] blur[12] (#252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * blur[12] * better fractional blur radius * fix blur2 * lift up radius checks * tiny optimization * blur2 takes {data, width, height} * skip blurh for blur1 * blurImage * remove unnecessary max * simpler inclusive stop * DRY * make height redundant * rename blur1 to blur Co-authored-by: Philippe Rivière --- src/blur.js | 183 +++++++++++++++++++---------------- src/index.js | 2 +- src/math.js | 5 - test/blur-test.js | 236 +++++++++++++++++++++++++++++++++++----------- 4 files changed, 286 insertions(+), 140 deletions(-) delete mode 100644 src/math.js diff --git a/src/blur.js b/src/blur.js index f37811c2..7021cbf3 100644 --- a/src/blur.js +++ b/src/blur.js @@ -1,94 +1,115 @@ -import {floor, max, min} from "./math.js"; -import {arrayify} from "./utils.js"; +export function blur(values, r) { + if (!((r = +r) >= 0)) throw new RangeError("invalid r"); + let length = values.length; + if (!((length = Math.floor(length)) >= 0)) throw new RangeError("invalid length"); + if (!length || !r) return values; + const blur = blurf(r); + const temp = values.slice(); + blur(values, temp, 0, length, 1); + blur(temp, values, 0, length, 1); + blur(values, temp, 0, length, 1); + return values; +} -function blurTransfer(V, r, n, vertical) { - if (!r) return; // radius 0 is a noop +export const blur2 = Blur2(blurf); - const iterations = 3, r0 = Math.floor(r); - // for a non-integer radius, interpolate between floor(r) and ceil(r) - if (r === r0) { - for (let i = 0; i < iterations; i++) { - blurTransferInt(V, r, n, vertical); - } - } else { - const frac = r - r0, frac_1 = 1 - frac; - const data = V[0].slice(); - for (let i = 0; i < iterations; i++) { - blurTransferInt(V, r0 + 1, n, vertical); - } - const data_ceil = V[0]; - V[0] = data; - if (r0 > 0) { - for (let i = 0; i < iterations; i++) { - blurTransferInt(V, r0, n, vertical); - } - } - for (let i = 0; i < data.length; i++) { - V[0][i] = V[0][i] * frac_1 + data_ceil[i] * frac; +export const blurImage = Blur2(blurfImage); + +function Blur2(blur) { + return function(data, rx, ry = rx) { + if (!((rx = +rx) >= 0)) throw new RangeError("invalid rx"); + if (!((ry = +ry) >= 0)) throw new RangeError("invalid ry"); + let {data: values, width, height} = data; + if (!((width = Math.floor(width)) >= 0)) throw new RangeError("invalid width"); + if (!((height = Math.floor(height !== undefined ? height : values.length / width)) >= 0)) throw new RangeError("invalid height"); + if (!width || !height || (!rx && !ry)) return data; + const blurx = rx && blur(rx); + const blury = ry && blur(ry); + const temp = values.slice(); + if (blurx && blury) { + blurh(blurx, temp, values, width, height); + blurh(blurx, values, temp, width, height); + blurh(blurx, temp, values, width, height); + blurv(blury, values, temp, width, height); + blurv(blury, temp, values, width, height); + blurv(blury, values, temp, width, height); + } else if (blurx) { + blurh(blurx, values, temp, width, height); + blurh(blurx, temp, values, width, height); + blurh(blurx, values, temp, width, height); + } else if (blury) { + blurv(blury, values, temp, width, height); + blurv(blury, temp, values, width, height); + blurv(blury, values, temp, width, height); } - } + return data; + }; } -function blurTransferInt(V, r, n, vertical) { - const [source, target] = V, - m = floor(source.length / n), - w = 2 * r + 1, - w1 = 1 / w, - ki = vertical ? m : 1, - kj = vertical ? 1 : n, - W = w * ki, - R = r * ki; +function blurh(blur, T, S, w, h) { + for (let y = 0, n = w * h; y < n;) { + blur(T, S, y, y += w, 1); + } +} - for (let j = 0; j < m; ++j) { - const k0 = kj * j, - kn = k0 + ki * (n - 1); - for (let i = 0, sr = w * source[k0]; i < n + r; ++i) { - const k = ki * i + kj * j; - sr += source[min(k, kn)] - source[max(k - W, k0)]; - target[max(k - R, k0)] = sr * w1; - } +function blurv(blur, T, S, w, h) { + for (let x = 0, n = w * h; x < w; ++x) { + blur(T, S, x, x + n, w); } - V.reverse(); // target becomes V[0] and will be used as source in the next iteration } -export default function blur() { - let rx = 5, - ry = rx, - value, - width; - const V = []; +function blurfImage(radius) { + const blur = blurf(radius); + return (T, S, start, stop, step) => { + start <<= 2, stop <<= 2, step <<= 2; + blur(T, S, start + 0, stop + 0, step); + blur(T, S, start + 1, stop + 1, step); + blur(T, S, start + 2, stop + 2, step); + blur(T, S, start + 3, stop + 3, step); + }; +} - function blur(data) { - // reuse the V arrays if possible - if (value || !V[0] || V[0].length !== data.length) { - V[0] = value ? Float32Array.from(data, value) : Float32Array.from(data); - V[1] = new Float32Array(V[0].length); - } else { - V[0].set(arrayify(data)); +// Given a target array T, a source array S, sets each value T[i] to the average +// of {S[i - r], …, S[i], …, S[i + r]}, where r = ⌊radius⌋, start <= i < stop, +// for each i, i + step, i + 2 * step, etc., and where S[j] is clamped between +// S[start] (inclusive) and S[stop] (exclusive). If the given radius is not an +// integer, S[i - r - 1] and S[i + r + 1] are added to the sum, each weighted +// according to r - ⌊radius⌋. +function blurf(radius) { + const radius0 = Math.floor(radius); + if (radius0 === radius) return bluri(radius); + const t = radius - radius0; + const w = 2 * radius + 1; + return (T, S, start, stop, step) => { // stop must be aligned! + if (!((stop -= step) >= start)) return; // inclusive stop + let sum = radius0 * S[start]; + const s0 = step * radius0; + const s1 = s0 + step; + for (let i = start, j = start + s0; i < j; i += step) { + sum += S[Math.min(stop, i)]; } + for (let i = start, j = stop; i <= j; i += step) { + sum += S[Math.min(stop, i + s0)]; + T[i] = (sum + t * (S[Math.max(start, i - s1)] + S[Math.min(stop, i + s1)])) / w; + sum -= S[Math.max(start, i - s0)]; + } + }; +} - const n = width || V[0].length; - const m = Math.round(V[0].length / n); - - blurTransfer(V, rx, n, false); - blurTransfer(V, ry, m, true); - - V[0].width = n; - V[0].height = m; - return V[0]; - } - - blur.radius = _ => _ === undefined - ? (rx + ry) / 2 - : (rx = ry = +_, blur); - blur.radiusX = _ => _ === undefined - ? rx : (rx = +_, blur); - blur.radiusY = _ => _ === undefined - ? ry : (ry = +_, blur); - blur.width = _ => - _ === undefined ? width : (width = Math.round(+_), blur); - blur.value = _ => - typeof _ === "function" ? (value = _, blur) : value; - - return blur; +// Like blurf, but optimized for integer radius. +function bluri(radius) { + const w = 2 * radius + 1; + return (T, S, start, stop, step) => { // stop must be aligned! + if (!((stop -= step) >= start)) return; // inclusive stop + let sum = radius * S[start]; + const s = step * radius; + for (let i = start, j = start + s; i < j; i += step) { + sum += S[Math.min(stop, i)]; + } + for (let i = start, j = stop; i <= j; i += step) { + sum += S[Math.min(stop, i + s)]; + T[i] = sum / w; + sum -= S[Math.max(start, i - s)]; + } + }; } diff --git a/src/index.js b/src/index.js index 497c2b71..29fc3346 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,7 @@ export {default as bisect, bisectRight, bisectLeft, bisectCenter} from "./bisect.js"; export {default as ascending} from "./ascending.js"; export {default as bisector} from "./bisector.js"; -export {default as blur} from "./blur.js"; +export {blur, blur2, blurImage} from "./blur.js"; export {default as count} from "./count.js"; export {default as cross} from "./cross.js"; export {default as cumsum} from "./cumsum.js"; diff --git a/src/math.js b/src/math.js deleted file mode 100644 index 0004e5c9..00000000 --- a/src/math.js +++ /dev/null @@ -1,5 +0,0 @@ -export const floor = Math.floor; - -export const max = Math.max; - -export const min = Math.min; diff --git a/test/blur-test.js b/test/blur-test.js index 6d1b6195..014757a4 100644 --- a/test/blur-test.js +++ b/test/blur-test.js @@ -1,72 +1,202 @@ import assert from "assert"; -import {blur} from "../src/index.js"; +import {blur, blur2} from "../src/index.js"; -it("blur() returns a default blur generator", () => { - const h = blur(); - assert.equal(h.radius(), 5); - assert.equal(h.radiusX(), 5); - assert.equal(h.radiusY(), 5); - assert.equal(h.width(), undefined); - assert.equal(h.value(), undefined); +it("blur(values, r) returns values", () => { + const V = [0, 0, 0, 0, 0, 0, 27, 0, 0, 0, 0, 0, 0, 0]; + assert.strictEqual(blur(V, 1), V); + assert.deepStrictEqual(V, [0, 0, 0, 1, 3, 6, 7, 6, 3, 1, 0, 0, 0, 0]); }); -it("blur() blurs in 1D", () => { - const h = blur(); - assert.deepEqual( - h.radius(1).width(0)([0, 0, 0, 0, 0, 0, 27, 0, 0, 0, 0, 0, 0, 0]), - Object.assign(Float32Array.from([0, 0, 0, 1, 3, 6, 7, 6, 3, 1, 0, 0, 0, 0]), { width: 14, height: 1 }) - ); +it("blur(values, r) observes the expected integer radius r", () => { + assert.deepStrictEqual(blur([0, 0, 0, 0, 0, 0, 27, 0, 0, 0, 0, 0, 0, 0], 0.0).map(round), [0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 27.00, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000]); + assert.deepStrictEqual(blur([0, 0, 0, 0, 0, 0, 27, 0, 0, 0, 0, 0, 0, 0], 1.0).map(round), [0.000, 0.000, 0.000, 1.000, 3.000, 6.000, 7.000, 6.000, 3.000, 1.000, 0.000, 0.000, 0.000, 0.000]); + assert.deepStrictEqual(blur([0, 0, 0, 0, 0, 0, 27, 0, 0, 0, 0, 0, 0, 0], 2.0).map(round), [0.216, 0.648, 1.296, 2.160, 3.240, 3.888, 4.104, 3.888, 3.240, 2.160, 1.296, 0.648, 0.216, 0.000]); + assert.deepStrictEqual(blur([0, 0, 0, 0, 0, 0, 27, 0, 0, 0, 0, 0, 0, 0], 3.0).map(round), [1.023, 1.338, 1.732, 2.204, 2.598, 2.834, 2.913, 2.834, 2.598, 2.204, 1.653, 1.181, 0.787, 0.472]); }); -it("blur() blurs in 2D", () => { - const h = blur(); - assert.deepEqual( - h.width(4).radiusX(1).radiusY(1)([0, 0, 0, 0, 729, 0, 0, 0, 0, 0, 0, 0]), - Object.assign(Float32Array.from([117, 81, 36, 9, 117, 81, 36, 9, 117, 81, 36, 9]), { width: 4, height: 3 }) - ); +it("blur(values, r) observes the expected fractional radius r", () => { + assert.deepStrictEqual(blur([0, 0, 0, 0, 0, 0, 27, 0, 0, 0, 0, 0, 0, 0], 0.5).map(round), [0.000, 0.000, 0.000, 0.422, 2.531, 6.328, 8.438, 6.328, 2.531, 0.422, 0.000, 0.000, 0.000, 0.000]); + assert.deepStrictEqual(blur([0, 0, 0, 0, 0, 0, 27, 0, 0, 0, 0, 0, 0, 0], 1.5).map(round), [0.053, 0.316, 0.949, 2.004, 3.322, 4.430, 4.852, 4.430, 3.322, 2.004, 0.949, 0.316, 0.053, 0.000]); + assert.deepStrictEqual(blur([0, 0, 0, 0, 0, 0, 27, 0, 0, 0, 0, 0, 0, 0], 2.5).map(round), [0.672, 1.078, 1.609, 2.234, 2.813, 3.188, 3.313, 3.188, 2.813, 2.234, 1.594, 1.031, 0.594, 0.281]); + assert.deepStrictEqual(blur([0, 0, 0, 0, 0, 0, 27, 0, 0, 0, 0, 0, 0, 0], 3.5).map(round), [1.266, 1.503, 1.780, 2.057, 2.294, 2.452, 2.505, 2.452, 2.294, 2.030, 1.701, 1.371, 1.081, 0.844]); }); -it("blur().radiusY(0) blurs horizontally", () => { - const h = blur(); - assert.deepEqual( - h.width(4).radiusX(1).radiusY(0)([27, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), - Object.assign(Float32Array.from([13, 9, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0]), { width: 4, height: 3 }) - ); +it("blur(values, r) repeats starting values before the window", () => { + assert.deepStrictEqual(blur([27, 0, 0, 0, 0, 0, 0, 0], 0.0).map(round), [27.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000]); + assert.deepStrictEqual(blur([27, 0, 0, 0, 0, 0, 0, 0], 1.0).map(round), [13.000, 9.000, 4.000, 1.000, 0.000, 0.000, 0.000, 0.000]); + assert.deepStrictEqual(blur([27, 0, 0, 0, 0, 0, 0, 0], 2.0).map(round), [11.016, 9.072, 6.696, 4.104, 2.160, 0.864, 0.216, 0.000]); + assert.deepStrictEqual(blur([27, 0, 0, 0, 0, 0, 0, 0], 3.0).map(round), [10.233, 8.974, 7.478, 5.825, 4.093, 2.676, 1.574, 0.787]); }); -it("blur().radiusX(0) blurs vertically", () => { - const h = blur(); - assert.deepEqual( - h.width(4).radiusX(0).radiusY(1)([0, 0, 0, 27, 3, -9, 0, 0, 0, 0, 0, 0]), - Object.assign(Float32Array.from([1, -3, 0, 13, 1, -3, 0, 9, 1, -3, 0, 5]), { width: 4, height: 3 }) - ); +it("blur(values, r) approximately preserves total value", () => { + assert.strictEqual(blur([0, 0, 0, 0, 0, 0, 27, 0, 0, 0, 0, 0, 0, 0], 0.0).reduce((p, v) => p + v), 27); + assert.strictEqual(blur([0, 0, 0, 0, 0, 0, 27, 0, 0, 0, 0, 0, 0, 0], 0.5).reduce((p, v) => p + v), 27); + assert.strictEqual(blur([0, 0, 0, 0, 0, 0, 27, 0, 0, 0, 0, 0, 0, 0], 1.0).reduce((p, v) => p + v), 27); + assert.strictEqual(blur([0, 0, 0, 0, 0, 0, 27, 0, 0, 0, 0, 0, 0, 0], 1.5).reduce((p, v) => p + v), 27); + assert.strictEqual(blur([0, 0, 0, 0, 0, 0, 27, 0, 0, 0, 0, 0, 0, 0], 2.0).reduce((p, v) => p + v), 27.000000000000004); + assert.strictEqual(blur([0, 0, 0, 0, 0, 0, 27, 0, 0, 0, 0, 0, 0, 0], 2.5).reduce((p, v) => p + v), 26.640625); + assert.strictEqual(blur([0, 0, 0, 0, 0, 0, 27, 0, 0, 0, 0, 0, 0, 0], 3.0).reduce((p, v) => p + v), 26.370262390670547); }); -it("blur().radius(0.5) does a fraction of blur", () => { - const h = blur().width(5), V = [0,0,0,0,0, 0,0,5184,0,0, 0,0,0,0,0]; - assert.deepEqual( - h.radius(0.5)(V), - Object.assign(Float32Array.from([64, 96, 544, 96, 64, 256, 384, 2176, 384, 256, 64, 96, 544, 96, 64]), { width: 5, height: 3 }) - ); +const unit = { + width: 11, + height: 11, + data: [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ] +}; + +it("blur2(data, r) modifies in-place", () => { + const copy = copy2(unit); + assert.strictEqual(blur2(copy, 1), copy); +}); + +it("data.height is redundant for blur2", () => { + const copy = copy2(unit); + delete copy.height; + assert.deepStrictEqual(blur2(copy, 1).data.map(round), [ + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.001, 0.004, 0.008, 0.010, 0.008, 0.004, 0.001, 0.000, 0.000, + 0.000, 0.000, 0.004, 0.012, 0.025, 0.029, 0.025, 0.012, 0.004, 0.000, 0.000, + 0.000, 0.000, 0.008, 0.025, 0.049, 0.058, 0.049, 0.025, 0.008, 0.000, 0.000, + 0.000, 0.000, 0.010, 0.029, 0.058, 0.067, 0.058, 0.029, 0.010, 0.000, 0.000, + 0.000, 0.000, 0.008, 0.025, 0.049, 0.058, 0.049, 0.025, 0.008, 0.000, 0.000, + 0.000, 0.000, 0.004, 0.012, 0.025, 0.029, 0.025, 0.012, 0.004, 0.000, 0.000, + 0.000, 0.000, 0.001, 0.004, 0.008, 0.010, 0.008, 0.004, 0.001, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000 + ]); }); -it("blur().radius(1.2) does a fraction of blur", () => { - const h = blur(), V = [0,0,0,0,0, 0,0,1,0,0, 0,0,0,0,0]; - const V1 = h.radius(1)(V); - const V2 = h.radius(2)(V); - for (let i = 0; i < V1.length; i++) V1[i] = 0.8 * V1[i] + 0.2 * V2[i]; - assert.deepEqual(Array.from(h.radius(1.2)(V)), Array.from(V1)); +it("blur2(data, r) observes the expected integer radius r", () => { + assert.deepStrictEqual(blur2(copy2(unit), 0), unit); + assert.deepStrictEqual(blur2(copy2(unit), 1).data.map(round), [ + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.001, 0.004, 0.008, 0.010, 0.008, 0.004, 0.001, 0.000, 0.000, + 0.000, 0.000, 0.004, 0.012, 0.025, 0.029, 0.025, 0.012, 0.004, 0.000, 0.000, + 0.000, 0.000, 0.008, 0.025, 0.049, 0.058, 0.049, 0.025, 0.008, 0.000, 0.000, + 0.000, 0.000, 0.010, 0.029, 0.058, 0.067, 0.058, 0.029, 0.010, 0.000, 0.000, + 0.000, 0.000, 0.008, 0.025, 0.049, 0.058, 0.049, 0.025, 0.008, 0.000, 0.000, + 0.000, 0.000, 0.004, 0.012, 0.025, 0.029, 0.025, 0.012, 0.004, 0.000, 0.000, + 0.000, 0.000, 0.001, 0.004, 0.008, 0.010, 0.008, 0.004, 0.001, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000 + ]); + assert.deepStrictEqual(blur2(copy2(unit), 2).data.map(round), [ + 0.001, 0.001, 0.002, 0.003, 0.003, 0.004, 0.003, 0.003, 0.002, 0.001, 0.001, + 0.001, 0.002, 0.004, 0.006, 0.007, 0.007, 0.007, 0.006, 0.004, 0.002, 0.001, + 0.002, 0.004, 0.006, 0.010, 0.012, 0.012, 0.012, 0.010, 0.006, 0.004, 0.002, + 0.003, 0.006, 0.010, 0.014, 0.017, 0.018, 0.017, 0.014, 0.010, 0.006, 0.003, + 0.003, 0.007, 0.012, 0.017, 0.021, 0.022, 0.021, 0.017, 0.012, 0.007, 0.003, + 0.004, 0.007, 0.012, 0.018, 0.022, 0.023, 0.022, 0.018, 0.012, 0.007, 0.004, + 0.003, 0.007, 0.012, 0.017, 0.021, 0.022, 0.021, 0.017, 0.012, 0.007, 0.003, + 0.003, 0.006, 0.010, 0.014, 0.017, 0.018, 0.017, 0.014, 0.010, 0.006, 0.003, + 0.002, 0.004, 0.006, 0.010, 0.012, 0.012, 0.012, 0.010, 0.006, 0.004, 0.002, + 0.001, 0.002, 0.004, 0.006, 0.007, 0.007, 0.007, 0.006, 0.004, 0.002, 0.001, + 0.001, 0.001, 0.002, 0.003, 0.003, 0.004, 0.003, 0.003, 0.002, 0.001, 0.001 + ]); }); -it("blur().radius() returns the (average) radius", () => { - const h = blur(); - assert.equal(h.width(2).radiusX(1).radiusY(1).radius(), 1); - assert.equal(h.width(2).radius(2).radius(), 2); - assert.equal(h.width(2).radiusX(1).radiusY(5).radius(), 3); +it("blur2(data, rx, 0) does horizontal blurring", () => { + assert.deepStrictEqual(blur2(copy2(unit), 0, 0).data, [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ]); + assert.deepStrictEqual(blur2(copy2(unit), 1, 0).data.map(round), [ + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.037, 0.111, 0.222, 0.259, 0.222, 0.111, 0.037, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000 + ]); + assert.deepStrictEqual(blur2(copy2(unit), 2, 0).data.map(round), [ + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.024, 0.048, 0.080, 0.120, 0.144, 0.152, 0.144, 0.120, 0.080, 0.048, 0.024, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000 + ]); }); -it("blur() accepts an iterable", () => { - const h = blur().radius(1); - assert.deepEqual(h(new Set([27, 0, -27])), Object.assign(Float32Array.from([8, 0, -8]), { width: 3, height: 1 })); - assert.deepEqual(h.value(d => d.a)(new Set([{a: 27}, {a: 0}, {a: -27}])), Object.assign(Float32Array.from([8, 0, -8]), { width: 3, height: 1 })); +it("blur2(data, 0, ry) does vertical blurring", () => { + assert.deepStrictEqual(blur2(copy2(unit), 0, 0).data, [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ]); + assert.deepStrictEqual(blur2(copy2(unit), 0, 1).data.map(round), [ + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.037, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.111, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.222, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.259, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.222, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.111, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.037, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000 + ]); + assert.deepStrictEqual(blur2(copy2(unit), 0, 2).data.map(round), [ + 0.000, 0.000, 0.000, 0.000, 0.000, 0.024, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.048, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.080, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.120, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.144, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.152, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.144, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.120, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.080, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.048, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.024, 0.000, 0.000, 0.000, 0.000, 0.000 + ]); }); + +function copy2({data, width, height}) { + return {data: data.slice(), width, height}; +} + +function round(x) { + return Math.round(x * 1000) / 1000 || 0; +} From 5bbef8f7c03ce138d6bdaa4da163b9221b843e31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sat, 2 Jul 2022 16:55:38 +0200 Subject: [PATCH 3/4] document --- README.md | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index e89132ca..f6c9adf8 100644 --- a/README.md +++ b/README.md @@ -685,35 +685,41 @@ d3.zip([1, 2], [3, 4]); // returns [[1, 3], [2, 4]] #### Blur -# blur() · [Source](https://github.com/d3/d3-array/blob/main/src/blur.js), [Examples](https://observablehq.com/@d3/d3-blur) +# d3.blur(*data*, *radius*) · [Source](https://github.com/d3/d3-array/blob/main/src/blur.js), [Examples](https://observablehq.com/@d3/d3-blur) -Creates a blur transformer, which can blur (or smooth) an *iterable* by three iterations of a moving average transform. +Blurs an array of *data* in-place by applying three iterations of a moving average transform, for a fast approximation of a gaussian kernel of the given *radius*, a non-negative number, and returns the array. -# *blur*.radius([*radius*]) - -If *radius* is specified, sets the radius of the transformation: on each iteration, the value of a point is transformed into the mean value of itself and the *radius* points of data surrounding it on the left and on the right (taking into account the edges). If *radius* is not specified, returns the current radius (if horizontal and vertical radii have been set separately, returns their average value). If *radius* is not an integer value, the blurring is applied partially. Defaults to 5. - -# *blur*.value([*value*]) - -If *value* is specified, sets the *value* accessor, which will read the values of the *iterator*. If not specified, returns the current accessor. Defaults to the special *null* accessor, which copies the values directly (faster than an identity function). - -Example: ```js -const blurred = blur().value(d => d.temperature)(data); +const randomWalk = d3.cumsum({length: 1000}, () => Math.random() - 0.5); +blur(randomWalk, 5); ``` -# *blur*.width([*width*]) +Copy the data if you don’t want to smooth it in-place: +```js +const smoothed = blur(randomWalk.slice(), 5); +``` -If *width* is specified, sets the width of the transformation, otherwise returns the current width. When 0 < width < length, *blur* considers that the *array* describes values in two dimensions—as a rectangle of a certain width (height inferred as length divided by width). In that case each iteration involves an horizontal (x) blurring, followed by a vertical (y) blurring. Defaults to undefined (horizontal dimension). +# d3.blur2({*data*, *width*[, *height*]}, *rx*[, *ry*]) · [Source](https://github.com/d3/d3-array/blob/main/src/blur.js), [Examples](https://observablehq.com/@d3/d3-blur) -# *blur*.radiusX([*radius*]) +Blurs a matrix of the given *width* and *height* in-place, by applying an horizontal blur of radius *rx* and a vertical blur or radius *ry* (which defaults to *rx*). The matrix *data* is stored in a flat array, used to determine the *height* if it is not specified. Returns the blurred {data, width, height}. -If *radius* is specified, sets the horizontal radius of the transformation, otherwise returns it. (Use 0 for vertical blurring.) +```js +data = [ + 1, 0, 0, + 0, 0, 0, + 0, 0, 1 +]; +blur2({data, width: 3}, 1); +``` -# *blur*.radiusY([*radius*]) +# d3.blurImage(*imageData*, *rx*[, *ry*]) · [Source](https://github.com/d3/d3-array/blob/main/src/blur.js), [Examples](https://observablehq.com/@d3/d3-blurimage) -If *radius* is specified, sets the vertical radius of the transformation, otherwise returns it. (Use 0 for horizontal blurring.) +Blurs an [ImageData](https://developer.mozilla.org/en-US/docs/Web/API/ImageData) structure in-place, blurring each of the RGBA layers independently by applying an horizontal blur of radius *rx* and a vertical blur or radius *ry* (which defaults to *rx*). Returns the blurred ImageData. +```js +const imData = context.getImageData(0, 0, width, height); +blurImage(imData, 5); +``` ### Iterables From fb6d714e5b711cd8fb09ac4deb2af46256af723d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sat, 2 Jul 2022 18:20:06 +0200 Subject: [PATCH 4/4] remove arrayify --- src/utils.js | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 src/utils.js diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index 1aaf7136..00000000 --- a/src/utils.js +++ /dev/null @@ -1,3 +0,0 @@ -export function arrayify(values) { - return typeof values !== "object" || "length" in values ? values : Array.from(values); -}