Add blur filter to Layer filters (#90)

Any good photo editing software should have a gaussian blur filter.
This commit is contained in:
Igor Zinken
2026-04-12 20:00:55 +02:00
committed by GitHub
parent 6eeed8c2bd
commit da3ae37dbd
7 changed files with 247 additions and 6 deletions

View File

@@ -1,7 +1,7 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2021-2025 - https://www.igorski.nl
* Igor Zinken 2021-2026 - https://www.igorski.nl
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -120,6 +120,15 @@
:tooltip="'none'"
/>
</div>
<div class="wrapper wrapper--slider">
<label v-t="'blur'"></label>
<slider
v-model="internalValue.blur"
:min="0"
:max="maxBlur"
:tooltip="'none'"
/>
</div>
<div class="wrapper wrapper--toggle">
<label
for="duotone"
@@ -181,6 +190,7 @@ import Slider from "@/components/ui/slider/slider.vue";
import { Layer, Filters } from "@/definitions/document";
import { BlendModes } from "@/definitions/blend-modes";
import FiltersFactory from "@/factories/filters-factory";
import { MAX_BLUR } from "@/rendering/filters/blur";
import KeyboardService from "@/services/keyboard-service";
import { updateLayerFilters } from "@/store/actions/layer-update-filters";
import { clone } from "@/utils/object-util";
@@ -299,8 +309,9 @@ export default {
}
},
created(): void {
this.orgFilters = clone( this.filters );
this.orgFilters = clone( this.filters );
this.internalValue = clone( this.filters );
this.maxBlur = MAX_BLUR;
KeyboardService.setListener( this.handleKeyUp.bind( this ), false );
},
mounted(): void {

View File

@@ -34,6 +34,7 @@
"threshold": "Threshold",
"desaturate": "Desaturate",
"duotone": "Duotone",
"blur": "Blur",
"color1": "Color 1",
"color2": "Color 2",
"reset": "Reset",

View File

@@ -86,6 +86,7 @@ export type Filters = {
color1?: string;
color2?: string;
};
blur: number;
};
export type Text = {

View File

@@ -1,7 +1,7 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2021-2025 - https://www.igorski.nl
* Igor Zinken 2021-2026 - https://www.igorski.nl
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -47,6 +47,7 @@ const FiltersFactory = {
color1: DEFAULT_DUOTONE_1,
color2: DEFAULT_DUOTONE_2,
},
blur = 0,
}: FiltersProps = {}): Filters {
return {
enabled,
@@ -60,6 +61,7 @@ const FiltersFactory = {
vibrance,
threshold,
duotone,
blur,
};
},
@@ -83,6 +85,7 @@ const FiltersFactory = {
de: duotone.enabled,
d1: duotone.color1,
d2: duotone.color2,
bl: filters.blur,
};
},
@@ -108,6 +111,7 @@ const FiltersFactory = {
color1: filters.d1 ?? DEFAULT_DUOTONE_1,
color2: filters.d2 ?? DEFAULT_DUOTONE_2,
},
blur: filters.bl,
});
}
};
@@ -139,5 +143,6 @@ export const isEqual = ( filters: Filters, filtersToCompareTo?: Filters ): boole
filters.threshold === filtersToCompareTo.threshold &&
filters.duotone.enabled === filtersToCompareTo.duotone.enabled &&
filters.duotone.color1 === filtersToCompareTo.duotone.color1 &&
filters.duotone.color2 === filtersToCompareTo.duotone.color2;
filters.duotone.color2 === filtersToCompareTo.duotone.color2 &&
filters.blur === filtersToCompareTo.blur;
};

View File

@@ -0,0 +1,210 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2026 - https://www.igorski.nl
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
export const MAX_BLUR = 50;
export const applyBlur = ( pixels: Uint8ClampedArray, width: number, height: number, radius: number ): void =>
{
const invertedRadius = (( MAX_BLUR - radius ) / MAX_BLUR ) * 100;
const out = new Uint32Array( pixels.length );
const tmpLines = new Float32Array( Math.max( width, height ) * 4 );
const source = new Uint32Array( pixels.buffer );
const coeffs = gaussCoef( width / invertedRadius );
convolve( source, out, tmpLines, coeffs, width, height );
convolve( out, source, tmpLines, coeffs, height, width );
};
/* internal methods */
/**
* Calculate gaussian blur using IRR filter
* https://software.intel.com/en-us/articles/iir-gaussian-blur-filter
*/
function gaussCoef( sigma: number ): Float32Array
{
sigma = Math.max( sigma, 0.5 );
const a = Math.exp( 0.726 * 0.726 ) / sigma;
const g1 = Math.exp( -a );
const g2 = Math.exp( -2 * a );
const k = ( 1 - g1 ) * ( 1 - g1 ) / ( 1 + 2 * a * g1 - g2 );
const a0 = k;
const a1 = k * ( a - 1 ) * g1;
const a2 = k * ( a + 1 ) * g1;
const a3 = -k * g2;
const b1 = 2 * g1;
const b2 = -g2;
const leftCorner = ( a0 + a1 ) / ( 1 - b1 - b2 );
const rightCorner = ( a2 + a3 ) / ( 1 - b1 - b2 );
return new Float32Array([ a0, a1, a2, a3, b1, b2, leftCorner, rightCorner ]);
}
/**
* Blurs and transposes src into dest
*/
function convolve(
src: Uint32Array, dest: Uint32Array, lines: Float32Array, coeffs: Float32Array, width: number, height: number
): void {
for ( let i = 0; i < height; i++ ) {
// RGBA values for source and destination pixel
let srcR: number, srcG: number, srcB: number, srcA: number;
let destR: number, destG: number, destB: number, destA: number;
let srcIndex = i * width;
let outIndex = i;
let lineIndex = 0;
// 1. left to right
let rgba = src[ srcIndex ];
let prevSrcR = rgba & 0xff;
let prevSrcG = ( rgba >> 8 ) & 0xff;
let prevSrcB = ( rgba >> 16 ) & 0xff;
let prevSrcA = ( rgba >> 24 ) & 0xff;
let prevPrevDestR = prevSrcR * coeffs[ 6 ];
let prevPrevDestG = prevSrcG * coeffs[ 6 ];
let prevPrevDestB = prevSrcB * coeffs[ 6 ];
let prevPrevDestA = prevSrcA * coeffs[ 6 ];
let prevDestR = prevPrevDestR;
let prevDestG = prevPrevDestG;
let prevDestB = prevPrevDestB;
let prevDestA = prevPrevDestA;
let a0 = coeffs[ 0 ];
let a1 = coeffs[ 1 ];
let b1 = coeffs[ 4 ];
let b2 = coeffs[ 5 ];
for ( let j = 0; j < width; j++ ) {
rgba = src[ srcIndex ];
srcR = rgba & 0xff;
srcG = ( rgba >> 8 ) & 0xff;
srcB = ( rgba >> 16 ) & 0xff;
srcA = ( rgba >> 24 ) & 0xff;
destR = srcR * a0 + prevSrcR * a1 + prevDestR * b1 + prevPrevDestR * b2;
destG = srcG * a0 + prevSrcG * a1 + prevDestG * b1 + prevPrevDestG * b2;
destB = srcB * a0 + prevSrcB * a1 + prevDestB * b1 + prevPrevDestB * b2;
destA = srcA * a0 + prevSrcA * a1 + prevDestA * b1 + prevPrevDestA * b2;
prevPrevDestR = prevDestR;
prevPrevDestG = prevDestG;
prevPrevDestB = prevDestB;
prevPrevDestA = prevDestA;
prevDestR = destR;
prevDestG = destG;
prevDestB = destB;
prevDestA = destA;
prevSrcR = srcR;
prevSrcG = srcG;
prevSrcB = srcB;
prevSrcA = srcA;
lines[ lineIndex ] = prevDestR;
lines[ lineIndex + 1 ] = prevDestG;
lines[ lineIndex + 2 ] = prevDestB;
lines[ lineIndex + 3 ] = prevDestA;
lineIndex += 4;
srcIndex++;
}
srcIndex--;
lineIndex -= 4;
outIndex += height * ( width - 1 );
// 2. right to left
rgba = src[ srcIndex ];
prevSrcR = rgba & 0xff;
prevSrcG = ( rgba >> 8 ) & 0xff;
prevSrcB = ( rgba >> 16 ) & 0xff;
prevSrcA = ( rgba >> 24 ) & 0xff;
prevPrevDestR = prevSrcR * coeffs[ 7 ];
prevPrevDestG = prevSrcG * coeffs[ 7 ];
prevPrevDestB = prevSrcB * coeffs[ 7 ];
prevPrevDestA = prevSrcA * coeffs[ 7 ];
prevDestR = prevPrevDestR;
prevDestG = prevPrevDestG;
prevDestB = prevPrevDestB;
prevDestA = prevPrevDestA;
srcR = prevSrcR;
srcG = prevSrcG;
srcB = prevSrcB;
srcA = prevSrcA;
a0 = coeffs[ 2 ];
a1 = coeffs[ 3 ];
for ( let j = width - 1; j >= 0; j-- ) {
destR = srcR * a0 + prevSrcR * a1 + prevDestR * b1 + prevPrevDestR * b2;
destG = srcG * a0 + prevSrcG * a1 + prevDestG * b1 + prevPrevDestG * b2;
destB = srcB * a0 + prevSrcB * a1 + prevDestB * b1 + prevPrevDestB * b2;
destA = srcA * a0 + prevSrcA * a1 + prevDestA * b1 + prevPrevDestA * b2;
prevPrevDestR = prevDestR;
prevPrevDestG = prevDestG;
prevPrevDestB = prevDestB;
prevPrevDestA = prevDestA;
prevDestR = destR;
prevDestG = destG;
prevDestB = destB;
prevDestA = destA;
prevSrcR = srcR;
prevSrcG = srcG;
prevSrcB = srcB;
prevSrcA = srcA;
rgba = src[ srcIndex ];
srcR = rgba & 0xff;
srcG = ( rgba >> 8 ) & 0xff;
srcB = ( rgba >> 16 ) & 0xff;
srcA = ( rgba >> 24 ) & 0xff;
rgba =
(( lines[ lineIndex ] + prevDestR ) << 0 ) +
(( lines[ lineIndex + 1 ] + prevDestG) << 8 ) +
(( lines[ lineIndex + 2 ] + prevDestB) << 16 ) +
(( lines[ lineIndex + 3 ] + prevDestA) << 24 );
dest[ outIndex ] = rgba;
srcIndex--;
lineIndex -= 4;
outIndex -= height;
}
}
}

View File

@@ -25,6 +25,7 @@ import FiltersFactory from "@/factories/filters-factory";
import { imageDataAsFloat } from "@/utils/wasm-util";
import type { WasmFilterInstance } from "@/utils/wasm-util";
import { applyAdjustments } from "@/rendering/filters/adjustments";
import { applyBlur } from "@/rendering/filters/blur";
import { applyDuotone } from "@/rendering/filters/duotone";
import wasmJs from "@/wasm/bin/filters.js";
@@ -80,6 +81,10 @@ self.addEventListener( "message", async ({ data }: MessageEvent ): Promise<void>
function renderFilters( imageData: ImageData, filters: Filters ): Uint8ClampedArray {
const pixels = imageData.data;
if ( filters.blur > 0 ) {
applyBlur( pixels, imageData.width, imageData.height, filters.blur );
}
applyAdjustments( pixels, filters );
if ( filters.duotone.enabled ) {
@@ -107,6 +112,7 @@ function renderFiltersWasm( imageData: ImageData, filters: any ): Uint8ClampedAr
const doInvert = invert !== defaultFilters.invert;
const doThreshold = threshold !== defaultFilters.threshold;
const doDuotone = filters.duotone.enabled !== defaultFilters.duotone.enabled;
const doBlur = filters.blur > 0;
// run WASM operations
@@ -114,7 +120,7 @@ function renderFiltersWasm( imageData: ImageData, filters: any ): Uint8ClampedAr
wasmInstance._filter(
memory, length,
gamma, brightness, contrast, vibrance,/* threshold, duotone.color1, duotone.color2 */
doGamma, desaturate, doBrightness, doContrast, doVibrance/*, doThreshold, doDuotone*/
doGamma, desaturate, doBrightness, doContrast, doVibrance/*, doThreshold, doDuotone, doBlur*/
);
});
}

View File

@@ -22,6 +22,7 @@ describe( "Filters factory", () => {
color1: DEFAULT_DUOTONE_1,
color2: DEFAULT_DUOTONE_2,
},
blur: 0,
});
});
@@ -42,6 +43,7 @@ describe( "Filters factory", () => {
color1: "#FF9900",
color2: "#ABABAB",
},
blur: 33,
});
expect( filters ).toEqual({
enabled: false,
@@ -59,6 +61,7 @@ describe( "Filters factory", () => {
color1: "#FF9900",
color2: "#ABABAB",
},
blur: 33,
});
});
});
@@ -81,6 +84,7 @@ describe( "Filters factory", () => {
color1: "#FF9900",
color2: "#ABABAB",
},
blur: 50,
});
const serialized = FiltersFactory.serialize( filters );
const deserialized = FiltersFactory.deserialize( serialized );
@@ -126,13 +130,16 @@ describe( "Filters factory", () => {
filter = FiltersFactory.create({ duotone: { enabled: true }});
expect( hasFilters( filter )).toBe( true );
filter = FiltersFactory.create({ blur: 25 });
expect( hasFilters( filter )).toBe( true );
// we don't need to check for duotone colors as the enabled flag for the Object is sufficient
});
});
it( "should know when two filters instances are equal", () => {
const defaultFilter = FiltersFactory.create();
[ "enabled", "blendMode", "opacity", "gamma", "brightness", "contrast", "vibrance", "threshold", "desaturate", "invert" ]
[ "enabled", "blendMode", "opacity", "gamma", "brightness", "contrast", "vibrance", "threshold", "desaturate", "invert", "blur" ]
.forEach( property => {
const filters = FiltersFactory.create({ [ property ]: .88 });
expect( isEqual( filters, defaultFilter )).toBe( false );