Added threshold filter

This commit is contained in:
Igor Zinken
2024-07-26 21:41:50 +02:00
parent 58e6c71b10
commit df4146ce39
12 changed files with 100 additions and 22 deletions

4
.env
View File

@@ -1,3 +1,7 @@
# Whether to support WASM filters
VITE_ENABLE_WASM_FILTERS=false
# ---------------------------------
# Third party storage configuration
# ---------------------------------

3
.gitignore vendored
View File

@@ -6,6 +6,8 @@ node_modules
# local env files
.env.local
.env.*.local
.env.production
.env.*.production
# Log files
npm-debug.log*
@@ -21,3 +23,4 @@ pnpm-debug.log*
*.njsproj
*.sln
*.sw?
build.sh

View File

@@ -163,10 +163,12 @@ Once the container is started, you can access BitMappery at `http://localhost:51
## WebAssembly
BitMappery can also use WebAssembly to increase performance of image manipulation. The source
code is C based and compiled to WASM using [Emscripten](https://github.com/emscripten-core/emscripten). Because this setup is a little more cumbersome, the repository contains precompiled binaries
in the _./src/wasm/bin/_-folder meaning you can omit this setup if you don't intend to make changes
to these sources.
BitMappery can also use WebAssembly to _potentially_ increase performance of image manipulation.
WebAssembly filtering is a user controllable feature in the preferences pane, as long as the `.env` file has set
support for `VITE_ENABLE_WASM_FILTERS` to true.
The source code is C based and compiled to WASM using [Emscripten](https://github.com/emscripten-core/emscripten). Because this setup is a little more cumbersome, the repository contains precompiled binaries in the _./src/wasm/bin/_-folder meaning you can
omit this setup if you don't intend to make changes to these sources.
If you do wish to make contributions on this end, to compile the source (_/src/wasm/_) C-code to WASM, you
will first need to prepare your environment (note the last _source_ call does not permanently update your paths):
@@ -187,7 +189,7 @@ npm run wasm
### Benchmarks
On a particular (low powered) configuration, running all filters on a particular source takes:
On a particular (deliberately low powered) configuration, running all filters at their heaviest setting on a particular source takes:
* 7000+ ms in JavaScript
* 558 ms in WebAssembly
@@ -195,7 +197,8 @@ On a particular (low powered) configuration, running all filters on a particular
* 603 ms in WebAssembly inside a Web Worker
Note that the WebAssembly Web Worker takes a performance hit from converting the ImageData buffer
to float32 prior to allocating the buffer in the WASM instance's memory. This can benefit from
to float32 prior to allocating the buffer in the WASM instance's memory. This could benefit from
further tweaking to see if it gets closer to the JavaScript Web Worker performance.
WebAssembly filtering is a user controllable feature in the preferences pane.
However, as in the current setup the JS solution alone is performant enough _and you would need to write the
filter code twice_, the default for WASM is disabled.

View File

@@ -1,7 +1,7 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2021-2023 - https://www.igorski.nl
* Igor Zinken 2021-2024 - 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
@@ -84,6 +84,15 @@
:tooltip="'none'"
/>
</div>
<div class="wrapper input">
<label v-t="'threshold'"></label>
<slider
v-model="threshold"
:min="-1"
:max="255"
:tooltip="'none'"
/>
</div>
<div class="wrapper input">
<label v-t="'desaturate'"></label>
<toggle-button
@@ -218,6 +227,14 @@ export default {
this.internalValue.vibrance = value / 100;
}
},
threshold: {
get(): number {
return this.internalValue.threshold;
},
set( value: number ): void {
this.internalValue.threshold = value;
}
},
},
watch: {
internalValue: {

View File

@@ -26,6 +26,7 @@
"contrast": "Contrast",
"brightness": "Brightness",
"vibrance": "Vibrance",
"threshold": "Threshold",
"desaturate": "Desaturate",
"reset": "Reset",
"cancel": "Cancel",

View File

@@ -1,7 +1,7 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2021-2022 - https://www.igorski.nl
* Igor Zinken 2021-2024 - 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
@@ -83,16 +83,17 @@ export default {
computed: {
...mapGetters([
"preferences",
"supportWASM",
]),
hasWebAssembly() {
return "WebAssembly" in window;
return this.supportWASM;
},
},
watch: {
internalValue: {
deep: true,
handler( value ) {
setWasmFilters( !!value.wasmFilters );
setWasmFilters( this.supportWASM && !!value.wasmFilters );
}
}
},

View File

@@ -58,6 +58,7 @@ export type Filters = {
brightness: number;
contrast: number;
vibrance: number;
threshold: number;
desaturate: boolean;
};

View File

@@ -1,7 +1,7 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2021-2023 - https://www.igorski.nl
* Igor Zinken 2021-2024 - 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
@@ -36,6 +36,7 @@ const FiltersFactory = {
brightness = .5,
contrast = 0,
vibrance = .5,
threshold = -1, // -1 == off, working range is 0 - 255
desaturate = false
}: FiltersProps = {}): Filters {
return {
@@ -47,6 +48,7 @@ const FiltersFactory = {
contrast,
desaturate,
vibrance,
threshold,
};
},
@@ -64,6 +66,7 @@ const FiltersFactory = {
c: filters.contrast,
d: filters.desaturate,
v: filters.vibrance,
t: filters.threshold,
};
},
@@ -81,6 +84,7 @@ const FiltersFactory = {
contrast: filters.c,
desaturate: filters.d,
vibrance: filters.v,
threshold: filters.t,
});
}
};
@@ -107,5 +111,6 @@ export const isEqual = ( filters: Filters, filtersToCompareTo?: Filters ): boole
filters.brightness === filtersToCompareTo.brightness &&
filters.contrast === filtersToCompareTo.contrast &&
filters.desaturate === filtersToCompareTo.desaturate &&
filters.vibrance === filtersToCompareTo.vibrance;
filters.vibrance === filtersToCompareTo.vibrance &&
filters.threshold === filtersToCompareTo.threshold;
};

View File

@@ -24,6 +24,9 @@ import type { ActionContext, Module } from "vuex";
import { isMobile } from "@/utils/environment-util";
import { setWasmFilters } from "@/services/render-service";
// @ts-expect-error 'import.meta' property not allowed (not an issue, Vite takes care of it)
const SUPPORT_WASM = import.meta.env.VITE_ENABLE_WASM_FILTERS === "true" && "WebAssembly" in window;
const STORAGE_KEY = "bpy_pref";
export type Preferences = {
@@ -54,6 +57,7 @@ const PreferencesModule: Module<PreferencesState, any> = {
// curried, so not reactive !
// @ts-expect-error using string as key
getPreference: ( state: PreferencesState ): ( n: string ) => string => ( name: string ): boolean => state.preferences[ name ],
supportWASM: (): boolean => SUPPORT_WASM,
},
mutations: {
setPreferences( state: PreferencesState, preferences: Preferences ): void {
@@ -74,7 +78,7 @@ const PreferencesModule: Module<PreferencesState, any> = {
if ( typeof preferences.antiAlias === "boolean" ) {
commit( "setAntiAlias", preferences.antiAlias );
}
setWasmFilters( !!preferences.wasmFilters );
setWasmFilters( SUPPORT_WASM && !!preferences.wasmFilters );
} catch {
// non-blocking
}

View File

@@ -81,8 +81,22 @@ inline void vibrance( float vibrance, float& r, float& g, float& b ) {
}
}
inline void threshold( float threshold, float& r, float& g, float& b ) {
float luma = r * 0.3 + g * 0.59 + b * 0.11;
luma = luma < threshold ? 0 : 255;
r = luma;
g = luma;
b = luma;
}
extern "C" {
void filter( float* pixels, int length, float vGamma, float vBrightness, float vContrast, float vVibrance, bool doGamma, bool doDesaturate, bool doBrightness, bool doContrast, bool doVibrance ) {
void filter(
float* pixels, int length,
float vGamma, float vBrightness, float vContrast, float vVibrance, /*float vThreshold,*/
bool doGamma, bool doDesaturate, bool doBrightness, bool doContrast, bool doVibrance/*, bool doThreshold*/
) {
float r, g, b, a;
float gammaSquared = vGamma * vGamma;
@@ -90,6 +104,7 @@ extern "C" {
r = pixels[ i ];
g = pixels[ i + 1 ];
b = pixels[ i + 2 ];
a = pixels[ i + 3 ];
if ( doGamma )
gamma( gammaSquared, r, g, b );
@@ -106,6 +121,9 @@ extern "C" {
if ( doVibrance )
vibrance( vVibrance, r, g, b );
// if ( doThreshold && a > 0 )
// threshold( vThreshold, r, g, b );
pixels[ i ] = r;
pixels[ i + 1 ] = g;
pixels[ i + 2 ] = b;

View File

@@ -84,10 +84,12 @@ function renderFilters( imageData: ImageData, filters: any ): Uint8ClampedArray
const contrast = Math.pow((( filters.contrast * 100 ) + 100 ) / 100, 2 ); // -100 to 100 range
const gamma = ( filters.gamma * 2 ); // 0 to 2 range
const vibrance = -(( filters.vibrance * 200 ) - 100 ); // -100 to 100 range
const { desaturate } = filters; // boolean
const { desaturate, threshold } = filters;
const pixels = imageData.data;
let r, g, b;//, a;
let r, g, b, a;
let grayScale, max, avg, amt;
const gammaSquared = gamma * gamma;
@@ -95,6 +97,7 @@ function renderFilters( imageData: ImageData, filters: any ): Uint8ClampedArray
const doContrast = filters.contrast !== defaultFilters.contrast;
const doGamma = filters.gamma !== defaultFilters.gamma;
const doVibrance = filters.vibrance !== defaultFilters.vibrance;
const doThreshold = filters.threshold !== defaultFilters.threshold;
// loop through the pixels, note we increment the iterator by four
// as each pixel is defined by four channel values : red, green, blue and the alpha channel
@@ -105,7 +108,7 @@ function renderFilters( imageData: ImageData, filters: any ): Uint8ClampedArray
r = pixels[ i ];
g = pixels[ i + 1 ];
b = pixels[ i + 2 ];
//a = pixels[ i + 3 ]; // currently no filter uses alpha channel
a = pixels[ i + 3 ];
// 1. adjust gamma
if ( doGamma ) {
@@ -153,6 +156,16 @@ function renderFilters( imageData: ImageData, filters: any ): Uint8ClampedArray
}
}
// 6. adjust threshold
if ( doThreshold && a > 0 ) {
let luma = r * 0.3 + g * 0.59 + b * 0.11;
luma = luma < threshold ? 0 : 255;
r = g = b = luma;
}
// commit the changes
pixels[ i ] = r;
pixels[ i + 1 ] = g;
@@ -169,20 +182,21 @@ function renderFiltersWasm( imageData: ImageData, filters: any ): Uint8ClampedAr
const contrast = Math.pow((( filters.contrast * 100 ) + 100 ) / 100, 2 ); // -100 to 100 range
const gamma = ( filters.gamma * 2 ); // 0 to 2 range
const vibrance = -(( filters.vibrance * 200 ) - 100 ); // -100 to 100 range
const { desaturate } = filters; // boolean
const { desaturate, threshold } = filters;
const doBrightness = filters.brightness !== defaultFilters.brightness;
const doContrast = filters.contrast !== defaultFilters.contrast;
const doGamma = filters.gamma !== defaultFilters.gamma;
const doVibrance = filters.vibrance !== defaultFilters.vibrance;
const doThreshold = filters.threshold !== defaultFilters.threshold;
// run WASM operations
return imageDataAsFloat( imageData, wasmInstance, ( memory, length ) => {
wasmInstance._filter(
memory, length,
gamma, brightness, contrast, vibrance,
doGamma, desaturate, doBrightness, doContrast, doVibrance
gamma, brightness, contrast, vibrance,/* threshold,*/
doGamma, desaturate, doBrightness, doContrast, doVibrance/*, doThreshold*/
);
});
}

View File

@@ -14,6 +14,7 @@ describe( "Filters factory", () => {
brightness: .5,
contrast: 0,
vibrance: .5,
threshold: -1,
desaturate: false,
});
});
@@ -27,6 +28,7 @@ describe( "Filters factory", () => {
brightness: .6,
contrast: .3,
vibrance: .2,
threshold: 127,
desaturate: true,
});
expect( filters ).toEqual({
@@ -37,6 +39,7 @@ describe( "Filters factory", () => {
brightness: .6,
contrast: .3,
vibrance: .2,
threshold: 127,
desaturate: true,
});
});
@@ -52,6 +55,7 @@ describe( "Filters factory", () => {
brightness: .6,
contrast: .3,
vibrance: .2,
threshold: 255,
desaturate: true,
});
const serialized = FiltersFactory.serialize( filters );
@@ -86,6 +90,9 @@ describe( "Filters factory", () => {
filter = FiltersFactory.create({ vibrance: .4 });
expect( hasFilters( filter )).toBe( true );
filter = FiltersFactory.create({ threshold: 0 });
expect( hasFilters( filter )).toBe( true );
filter = FiltersFactory.create({ desaturate: true });
expect( hasFilters( filter )).toBe( true );
});
@@ -93,7 +100,7 @@ describe( "Filters factory", () => {
it( "should know when two filters instances are equal", () => {
const defaultFilter = FiltersFactory.create();
[ "enabled", "blendMode", "opacity", "gamma", "brightness", "contrast", "vibrance", "desaturate" ].forEach( property => {
[ "enabled", "blendMode", "opacity", "gamma", "brightness", "contrast", "vibrance", "threshold", "desaturate" ].forEach( property => {
const filters = FiltersFactory.create({ [ property ]: .88 });
expect( isEqual( filters, defaultFilter )).toBe( false );
});