Skip to content

[FEAT] Add Gaussian Blur to RawImage #1103

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 121 additions & 1 deletion src/utils/image.js
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,126 @@ export class RawImage {
}
}

/**
* Generates a 1D Gaussian kernel.
* @param {number} kernelSize - Kernel size (must be odd).
* @param {number} sigma - Standard deviation of the Gaussian.
* @returns {Array<number>} - The 1D Gaussian kernel.
*/
generateGaussianKernel(kernelSize, sigma) {
// Kernel must be odd because each pixel must sit evenly in the middle.
if (kernelSize % 2 === 0) {
throw new Error('Kernel size must be odd.');
}
Comment on lines +661 to +664
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason for this, is because a kernel is essentially a square that is applied to every pixel in an image.

Consider a grid of 5x5, it would look like this, with our pixel (P) in the middle.

. . . . .
. . . . .
. . P . .
. . . . .
. . . . .

Here, it is clear to see that there is an even spacing around the pixel.

Now, consider what would happen if we used an even kernel size of 4.

. . . .
. . . .
. . P .
. . . .

The pixel does not sit neatly in the middle of the kernel and this is a problem.


const kernel = [];
const center = Math.floor(kernelSize / 2);
const sigma2 = sigma * sigma;
let sum = 0;

for (let i = 0; i < kernelSize; i++) {
const x = i - center;
const value = Math.exp(-(x * x) / (2 * sigma2));
kernel[i] = value;
sum += value;
}

// Normalize the kernel
for (let i = 0; i < kernelSize; i++) {
kernel[i] /= sum;
}

return kernel;
}

/**
* Performs a Gaussian blur on the image.
* @param {number} kernelSize - Kernel size (must be odd).
* @param {number} sigma - Standard deviation of the Gaussian.
* @returns {Promise<RawImage>} - The blurred image.
*/
async gaussianBlur(kernelSize = 3, sigma = 1) {
const kernel = this.generateGaussianKernel(kernelSize, sigma);
const halfSize = Math.floor(kernelSize / 2);

const width = this.width;
const height = this.height;
const channels = this.channels;

// Rather than checking an entire grid of elements, we can instead do
// two separate passes with a 1d array (rather than 2d).
// Consider a 3x3 kernel, instead of calculating each pixel 9 times, we
// can instead calculate two sets of 3 values and then combine them.
const horizontalPass = new Float32Array(this.data.length);
const verticalPass = new Uint8ClampedArray(this.data.length);

const numChunks = navigator.hardwareConcurrency || 4;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was originally a hard coded value, passed into the function, however this approach makes more sense.
Looking at the table in the opening post, it shows that (at least for my machine) there is no benefit to increase the chunks above 4.

const chunkHeight = Math.ceil(height / numChunks);

// Process horizontal pass in chunks.
const horizontalPassPromises = Array.from({ length: numChunks }, (_, chunkIndex) => {
return new Promise(resolve => {
const startY = chunkIndex * chunkHeight;
const endY = Math.min(startY + chunkHeight, height);

for (let y = startY; y < endY; y++) {
for (let x = 0; x < width; x++) {
for (let c = 0; c < channels; c++) {
let sum = 0;

for (let kx = -halfSize; kx <= halfSize; kx++) {
const pixelX = Math.min(Math.max(x + kx, 0), width - 1);
const dataIndex = (((y * width) + pixelX) * channels) + c;
const kernelValue = kernel[kx + halfSize];
sum += this.data[dataIndex] * kernelValue;
}

const outputIndex = (((y * width) + x) * channels) + c;
horizontalPass[outputIndex] = sum;
}
}
}
resolve();
});
});

// Wait for all horizontal chunks to complete.
await Promise.all(horizontalPassPromises);

// Process vertical pass in chunks.
const verticalPassPromises = Array.from({ length: numChunks }, (_, chunkIndex) => {
return new Promise(resolve => {
const startY = chunkIndex * chunkHeight;
const endY = Math.min(startY + chunkHeight, height);

for (let y = startY; y < endY; y++) {
for (let x = 0; x < width; x++) {
for (let c = 0; c < channels; c++) {
let sum = 0;

for (let ky = -halfSize; ky <= halfSize; ky++) {
const pixelY = Math.min(Math.max(y + ky, 0), height - 1);
const dataIndex = (((pixelY * width) + x) * channels) + c;
const kernelValue = kernel[ky + halfSize];
sum += horizontalPass[dataIndex] * kernelValue;
}

const outputIndex = (((y * width) + x) * channels) + c;
verticalPass[outputIndex] = sum;
}
}
}
resolve();
});
});

// Wait for all vertical chunks to complete.
await Promise.all(verticalPassPromises);

this.data = verticalPass;
return this;
}

async toBlob(type = 'image/png', quality = 1) {
if (!IS_BROWSER_OR_WEBWORKER) {
throw new Error('toBlob() is only supported in browser environments.')
Expand Down Expand Up @@ -699,7 +819,7 @@ export class RawImage {
/**
* Split this image into individual bands. This method returns an array of individual image bands from an image.
* For example, splitting an "RGB" image creates three new images each containing a copy of one of the original bands (red, green, blue).
*
*
* Inspired by PIL's `Image.split()` [function](https://pillow.readthedocs.io/en/latest/reference/Image.html#PIL.Image.Image.split).
* @returns {RawImage[]} An array containing bands.
*/
Expand Down