How to Apply an Image Filter to a Photo with JavaScript

Filters are commonly used to adjust the rendering of images. We can use it to make images sharper, remove unwanted objects, or adjust the color tone.

In this article, we are going to build a JavaScript library to perform various image filters. It can be used together with Dynamsoft Document Viewer so that it can be easily integated into a document scanning workflow.

Online demo

New Project

Create a new project with Vite and the typescript template:

npm create vite@latest ImageFilter -- --template vanilla-ts

Implement the Image Filter

Next, let’s implement the image filter.

Define an Interface

Define an ImageFilter interface in src/ImageFilter.ts.

export interface ImageFilter {
  cvs:HTMLCanvasElement;
  process(img:HTMLImageElement|HTMLCanvasElement|HTMLVideoElement): void;
  convert(r:number,g:number,b:number,a:number): {r:number,g:number,b:number,a:number};
}

An image filter has two functions. One is a process function which saves the image with the filter applied onto a canvas. The other is a convert function which defines how the pixels are converted exactly.

Define a Base Filter Class

Create a GenericImageFilter class which implements ImageFilter in src/GenericImageFilter.ts. Here, we draw the image onto a canvas to manipulate its pixels.

export class GenericImageFilter implements ImageFilter {
  cvs:HTMLCanvasElement;
  constructor(cvs:HTMLCanvasElement) {
    this.cvs = cvs;
  }

  process(img:HTMLImageElement|HTMLCanvasElement|HTMLVideoElement){
    let width;
    let height;
    if (img instanceof HTMLImageElement) {
      width = img.naturalWidth;
      height = img.naturalHeight;
    }else if (img instanceof HTMLCanvasElement){
      width = img.width;
      height = img.height;
    }else{
      width = img.videoWidth;
      height = img.videoHeight;
    }
    const context = this.cvs.getContext('2d');
    this.cvs.width = width;
    this.cvs.height = height;
    if (context) {
      context.drawImage(img, 0, 0);
      const imageData = context.getImageData(0, 0, this.cvs.width, this.cvs.height);
      const pixels = imageData.data; //[r,g,b,a,...]
      for (var i = 0; i < pixels.length; i += 4) {
        const red = pixels[i];
        const green = pixels[i + 1];
        const blue = pixels[i + 2];
        const alpha = pixels[i + 3];
        const converted = this.convert(red, green, blue, alpha)
        pixels[i] = converted.r;
        pixels[i + 1] = converted.g;
        pixels[i + 2] = converted.b;
        pixels[i + 3] = converted.a;
      }
      context.putImageData(imageData, 0, 0);
    }
  }

  convert(r:number,g:number,b:number,a:number){
    return {r:r,g:g,b:b,a:a};
  }
}

Define Filters Derived from the Base

Next, we can simply create new image filters which extend the base class and override its functions.

  1. Grayscale Filter.

    export class GrayscaleFilter extends GenericImageFilter {
      convert(r: number, g: number, b: number, a: number): { r: number; g: number; b: number; a: number; } {
        const gray = (r * 6966 + g * 23436 + b * 2366) >> 15;
        return {r:gray,g:gray,b:gray,a:a};
      }
    }
    

    It turns an image into a grayscale one composed only of 256 different shades of gray.

    grayscale

  2. Sepia Filter.

    export class SepiaFilter extends GenericImageFilter {
      convert(r: number, g: number, b: number, a: number): { r: number; g: number; b: number; a: number; } {
        const red = (r * 0.393)+(g * 0.769)+(b * 0.189);
        const green = (r * 0.349)+(g * 0.686)+(b * 0.168);
        const blue = (r * 0.272)+(g * 0.534)+(b * 0.131);
        return {r:red,g:green,b:blue,a:a};
      }
    }
    

    It adds a reddish-brown tone to an image.

    sepia

  3. Invert Filter.

    export class InvertFilter extends GenericImageFilter {
      convert(r: number, g: number, b: number, a: number): { r: number; g: number; b: number; a: number; } {
        r = 255 - r;
        g = 255 - g;
        b = 255 - b;
        return {r:r,g:g,b:b,a:a};
      }
    }
    

    It inverts the pixels of an image. It is useful to process scanned negative films.

    Invert

    Image Source

  4. Black and White Filter. This filter is a bit complex. We have to override both process and convert functions. In addition, its constructor is also modified to accept two extra arguments: threshold and otsuEnabled. If otsuEnabled is set to true, the threshold will be calculated automatically using the OTSU’s method.

    import otsu from 'otsu';
    
    export class BlackwhiteFilter extends GenericImageFilter {
      threshold:number = 127;
      otsuEnabled:boolean = false;
      constructor(cvs:HTMLCanvasElement,threshold:number,otsuEnabled:boolean){
        super(cvs);
        this.threshold = threshold;
        this.otsuEnabled = otsuEnabled;
      }
    
      process(img:HTMLImageElement|HTMLCanvasElement|HTMLVideoElement):number{
        let width;
        let height;
        if (img instanceof HTMLImageElement) {
          width = img.naturalWidth;
          height = img.naturalHeight;
        }else if(img instanceof HTMLCanvasElement){
          width = img.width;
          height = img.height;
        }else{
          width = img.videoWidth;
          height = img.videoHeight;
        }
        const context = this.cvs.getContext('2d');
        this.cvs.width = width;
        this.cvs.height = height;
        let threshold;
        if (context) {
          context.drawImage(img, 0, 0);
          const imageData = context.getImageData(0, 0, this.cvs.width, this.cvs.height);
          const pixels = imageData.data; //[r,g,b,a,...]
          const grayscaleValues = [];
          for (var i = 0; i < pixels.length; i += 4) {
            const red = pixels[i];
            const green = pixels[i + 1];
            const blue = pixels[i + 2];
            const grayscale = this.grayscale(red, green, blue);
            grayscaleValues.push(grayscale);
          }
          if (this.otsuEnabled) {
            threshold = otsu(grayscaleValues);
          }else{
            threshold = this.threshold;
          }
          let grayscaleIndex = 0;
          for (var i = 0; i < pixels.length; i += 4) {
            const gray = grayscaleValues[grayscaleIndex];
            grayscaleIndex = grayscaleIndex + 1;
            let value = 255;
            if (gray < threshold) {
              value = 0;
            }
            pixels[i] = value;
            pixels[i + 1] = value;
            pixels[i + 2] = value;
          }
          context.putImageData(imageData, 0, 0);
        }
        return threshold;
      }
    
      grayscale(r: number, g: number, b: number): number {
        return (r * 6966 + g * 23436 + b * 2366) >> 15;
      }
    
      setThreshold(threshold:number){
        this.threshold = threshold;
      }
    
      setOTSUEnabled(enabled:boolean){
        this.otsuEnabled = enabled;
      }
    }
    

    black and white

Work with Dynamsoft Document Viewer

Dynamsoft Document Viewer provides several viewers for the document scanning process. We can use its Edit Viewer to view and edit scanned document images.

It provides an interface to allow defining a custom handler to use third-party image filters.

Let’s define such a handler so that we can use the image filter library in a document scanning process.

  1. Create a new file named FilterHandler.ts with the following template.

    let DDV;
    //allows setting the DDV namespace. It is needed if Dynamsoft Document Viewer (DDV) is installed with NPM.
    export function setDDV(DocumentViewer:any) {
      DDV = DocumentViewer;
    }
    if ((window as any)["Dynamsoft"]) {
      const Dynamsoft = (window as any)["Dynamsoft"];
      DDV = Dynamsoft.DDV;
    }
    
    export class ImageFilterHandler extends DDV.ImageFilter  {}
    
  2. Override the querySupported function in the class which returns a list of filters.

    querySupported() {
      return [
        {
          type: "original",
          label: "Original"
        },
        {
          type: "grayscale",
          label: "Gray",
        },
        {
          type: "BW",
          label: "B&W"
        },
        {
          type: "invert",
          label: "Invert"
        },
        {
          type: "sepia",
          label: "Retro",
        }
      ]
    };
    
  3. Override the applyFilter function to apply the selected image filter.

    async applyFilter(image:any, type:string) {
      if (type === "original") {
        return new Promise((r, _j) => {
          r(image.data)
        });
      }else{
        let img = await imageFromBlob(image.data);
        if (type === "BW") {
          let blackwhiteFilter = new BlackwhiteFilter(canvas,127,true);
          blackwhiteFilter.process(img);
        }else if (type === "sepia") {
          let sepiaFilter = new SepiaFilter(canvas);
          sepiaFilter.process(img);
        }else if (type === "grayscale") {
          let grayscaleFilter = new GrayscaleFilter(canvas);
          grayscaleFilter.process(img);
        }else if (type === "invert") {
          let invertFilter = new InvertFilter(canvas);
          invertFilter.process(img);
        }
        let blob = await canvasToBlob();
        return new Promise((r, _j) => {
          r(blob)
        });
      }
    };
    

    We have to use the following functions to convert the blob provided in the image to an image element for the filters to use and convert the canvas as blob for the handler to use.

    const canvasToBlob = async () => {
      return new Promise<Blob>((resolve, reject) => {
        canvas.toBlob((blob) => {
          if (blob) {
            resolve(blob);
          }else{
            reject();
          }
        },"image/jpeg",100);
      })
    }
    
    const imageFromBlob = async (blob:Blob):Promise<HTMLImageElement> => {
      return new Promise<HTMLImageElement>((resolve, _reject) => {
        let img = document.createElement("img");
        img.onload = function () {
          resolve(img);
        }
        let url = URL.createObjectURL(blob);
        img.src = url;
      })
    }
    
  4. Use original as the default filter.

    get defaultFilterType() {
      return "original"
    };
    
  5. Use the handler to create a new instance of Edit Viewer.

    let filterHandler = new ImageFilterHandler();
    // Configure image filter feature
    Dynamsoft.DDV.setProcessingHandler("imageFilter", filterHandler);
    // Create an edit viewer
    editViewer = new Dynamsoft.DDV.EditViewer({
      container: "container",
    });
    

Open Edit Viewer and we can see that we can use the image filters in its UI.

edit viewer

Package as a Library

We can publish it as a library onto NPM for ease of use.

  1. Install devDependencies:

    npm install -D @types/node vite-plugin-dts
    
  2. Create a new vite.config.ts file:

    // vite.config.ts
    import { resolve } from 'path';
    import { defineConfig } from 'vite';
    import dts from 'vite-plugin-dts';
    // https://vitejs.dev/guide/build.html#library-mode
    export default defineConfig({
      build: {
        lib: {
          entry: resolve(__dirname, 'src/index.ts'),
          name: 'image-filter',
          fileName: 'image-filter',
        },
      },
      plugins: [dts()],
    });
    
  3. Add the entry points of our package to package.json.

    {
      "main": "./dist/image-filter.umd.cjs",
      "module": "./dist/image-filter.js",
      "types": "./dist/index.d.ts",
      "exports": {
        "import": {
          "types": "./dist/index.d.ts",
          "default": "./dist/image-filter.js"
        },
        "require": {
          "types": "./dist/index.d.ts",
          "default": "./dist/image-filter.umd.cjs"
        }
      },
      "files": [
        "dist/*.css",
        "dist/*.js",
        "dist/*.cjs",
        "dist/*.d.ts"
      ]
    }
    

Run npm run build. Then, we can have the packaged files in the dist.

Source Code

Get the source code of the library to have a try:

https://github.com/tony-xlh/image-filter