mirror of
https://github.com/igorski/bitmappery.git
synced 2026-06-16 19:25:38 +02:00
Add blur filter to Layer filters (#90)
Any good photo editing software should have a gaussian blur filter.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"threshold": "Threshold",
|
||||
"desaturate": "Desaturate",
|
||||
"duotone": "Duotone",
|
||||
"blur": "Blur",
|
||||
"color1": "Color 1",
|
||||
"color2": "Color 2",
|
||||
"reset": "Reset",
|
||||
|
||||
@@ -86,6 +86,7 @@ export type Filters = {
|
||||
color1?: string;
|
||||
color2?: string;
|
||||
};
|
||||
blur: number;
|
||||
};
|
||||
|
||||
export type Text = {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
210
src/rendering/filters/blur.ts
Normal file
210
src/rendering/filters/blur.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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*/
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 );
|
||||
|
||||
Reference in New Issue
Block a user